feat(payment): connect payment service flow

This commit is contained in:
2026-06-18 15:40:59 +08:00
parent 35c30ac31e
commit a347b39001
34 changed files with 759 additions and 889 deletions
+111 -36
View File
@@ -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{" "}