feat(payment): connect payment service flow
This commit is contained in:
@@ -14,48 +14,110 @@
|
||||
* 7. 主操作按钮
|
||||
* 8. 协议复选框
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { Checkbox, MobileShell } from "@/app/_components/core";
|
||||
import { AppConstants } from "@/core/app_constants";
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
type SubscriptionPlanId,
|
||||
} from "@/data/constants/subscription-plans";
|
||||
import type { PaymentPlan } from "@/data/dto/payment";
|
||||
import { VIP_BENEFITS } from "@/data/constants/vip-benefits";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
} from "@/stores/payment/payment-context";
|
||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
SubscriptionBackLink,
|
||||
SubscriptionBanner,
|
||||
SubscriptionBenefitsCard,
|
||||
SubscriptionCheckoutButton,
|
||||
SubscriptionManageButton,
|
||||
SubscriptionCtaButton,
|
||||
SubscriptionPlanCard,
|
||||
SubscriptionUserRow,
|
||||
} from "./components";
|
||||
import styles from "./components/subscription-screen.module.css";
|
||||
import type { SubscriptionPlanView } from "./components/subscription-plan-card";
|
||||
|
||||
const FALLBACK_USERNAME = "User name";
|
||||
|
||||
function isVipPlan(plan: PaymentPlan): boolean {
|
||||
return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_");
|
||||
}
|
||||
|
||||
function currencySymbol(currency: string): string {
|
||||
if (currency === "CNY") return "¥";
|
||||
if (currency === "USD") return "$";
|
||||
return `${currency} `;
|
||||
}
|
||||
|
||||
function formatAmount(amountCents: number): string {
|
||||
return (amountCents / 100).toFixed(2).replace(/\.00$/, "");
|
||||
}
|
||||
|
||||
function planCaption(plan: PaymentPlan): string {
|
||||
if (plan.vipDays === null) return "Lifetime";
|
||||
const perDay = plan.amountCents / 100 / Math.max(plan.vipDays, 1);
|
||||
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
|
||||
}
|
||||
|
||||
function toPlanView(plan: PaymentPlan, index: number): SubscriptionPlanView {
|
||||
return {
|
||||
id: plan.planId,
|
||||
name: plan.planName,
|
||||
price: formatAmount(plan.amountCents),
|
||||
originalPrice: null,
|
||||
perDay: planCaption(plan),
|
||||
highlight: plan.planId === "vip_monthly" || index === 0,
|
||||
currencySymbol: currencySymbol(plan.currency),
|
||||
};
|
||||
}
|
||||
|
||||
export function SubscriptionScreen() {
|
||||
const user = useUserState();
|
||||
const userDispatch = useUserDispatch();
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const refreshedPaidOrderRef = useRef<string | null>(null);
|
||||
|
||||
const initialPlanId =
|
||||
SUBSCRIPTION_PLANS.find((p) => p.highlight)?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (payment.status === "idle") {
|
||||
paymentDispatch({ type: "PaymentInit" });
|
||||
}
|
||||
}, [payment.status, paymentDispatch]);
|
||||
|
||||
// 强制类型为 `SubscriptionPlanId` —— 套餐表只有 3 个 id,不会越界
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<SubscriptionPlanId | null>(
|
||||
initialPlanId as SubscriptionPlanId | null,
|
||||
useEffect(() => {
|
||||
if (!payment.isPaid || !payment.currentOrderId) return;
|
||||
if (refreshedPaidOrderRef.current === payment.currentOrderId) return;
|
||||
refreshedPaidOrderRef.current = payment.currentOrderId;
|
||||
userDispatch({ type: "UserFetch" });
|
||||
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
|
||||
|
||||
const vipPlans = useMemo(
|
||||
() =>
|
||||
payment.plans
|
||||
.filter(isVipPlan)
|
||||
.map((plan, index) => toPlanView(plan, index)),
|
||||
[payment.plans],
|
||||
);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
|
||||
const canActivate = selectedPlanId !== null && agreed;
|
||||
const isVip = user.currentUser?.isVip ?? false;
|
||||
const stripeCustomerId = user.currentUser?.stripeCustomerId ?? null;
|
||||
const selectedPlan =
|
||||
vipPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
|
||||
const isPaymentBusy =
|
||||
payment.isCreatingOrder ||
|
||||
payment.isPollingOrder ||
|
||||
payment.isCheckingVipStatus;
|
||||
const canActivate =
|
||||
selectedPlan !== null && payment.agreed && !isPaymentBusy;
|
||||
const isVip =
|
||||
user.currentUser?.isVip === true ||
|
||||
payment.vipStatus?.isVip === true ||
|
||||
payment.isPaid;
|
||||
|
||||
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
||||
const avatarUrl = user.currentUser?.avatarUrl ?? null;
|
||||
const autoRenewCaption = payment.autoRenew
|
||||
? "It will automatically renew upon expiration, and can be canceled at any time"
|
||||
: "This plan is a one-time purchase and will not automatically renew";
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
@@ -76,20 +138,31 @@ export function SubscriptionScreen() {
|
||||
className={styles.plansRow}
|
||||
aria-label="Choose a subscription plan"
|
||||
>
|
||||
{SUBSCRIPTION_PLANS.map((plan) => (
|
||||
<SubscriptionPlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
selected={selectedPlanId === plan.id}
|
||||
onClick={() => setSelectedPlanId(plan.id)}
|
||||
/>
|
||||
))}
|
||||
{vipPlans.length > 0 ? (
|
||||
vipPlans.map((plan, index) => (
|
||||
<SubscriptionPlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
gradientIndex={index}
|
||||
selected={payment.selectedPlanId === plan.id}
|
||||
onClick={() =>
|
||||
paymentDispatch({
|
||||
type: "PaymentPlanSelected",
|
||||
planId: plan.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className={styles.plansStatus}>
|
||||
{payment.isLoadingPlans
|
||||
? "Loading membership plans..."
|
||||
: payment.errorMessage ?? "Membership plans are unavailable."}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className={styles.autoRenewCaption}>
|
||||
It will automatically renew upon expiration, and can be canceled at
|
||||
any time
|
||||
</p>
|
||||
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
|
||||
|
||||
<section className={styles.benefitsSlot}>
|
||||
<SubscriptionBenefitsCard
|
||||
@@ -99,15 +172,12 @@ export function SubscriptionScreen() {
|
||||
</section>
|
||||
|
||||
<div className={styles.ctaSlot}>
|
||||
{/*
|
||||
* 已为 VIP 的用户 → 显示 Manage VIP 按钮(跳转到 Stripe Customer Portal)
|
||||
* VIP 状态从 `user.isVip` 取(webhook 会设置)
|
||||
*/}
|
||||
{isVip ? (
|
||||
<SubscriptionManageButton customerId={stripeCustomerId} />
|
||||
<SubscriptionCtaButton type="button" disabled>
|
||||
VIP Activated
|
||||
</SubscriptionCtaButton>
|
||||
) : (
|
||||
<SubscriptionCheckoutButton
|
||||
planId={selectedPlanId ?? "monthly"}
|
||||
disabled={!canActivate}
|
||||
/>
|
||||
)}
|
||||
@@ -115,8 +185,13 @@ export function SubscriptionScreen() {
|
||||
|
||||
<div className={styles.agreementSlot}>
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onChange={setAgreed}
|
||||
checked={payment.agreed}
|
||||
onChange={(agreed) =>
|
||||
paymentDispatch({
|
||||
type: "PaymentAgreementChanged",
|
||||
agreed,
|
||||
})
|
||||
}
|
||||
label={
|
||||
<>
|
||||
I agree to the{" "}
|
||||
|
||||
Reference in New Issue
Block a user