"use client"; /** * 订阅页主屏幕 * * 原始 Dart: lib/ui/subscription/... * * 布局(顶到底): * 1. 顶部返回箭头 * 2. 用户信息行 * 3. 渐变横幅 * 4. 三个套餐卡片 * 5. 自动续费说明 * 6. 权益列表卡片 * 7. 主操作按钮 * 8. 协议复选框 */ import { useEffect, useMemo, useRef } from "react"; import { Checkbox, MobileShell } from "@/app/_components/core"; import { AppConstants } from "@/core/app_constants"; import type { PaymentPlan } from "@/data/dto/payment"; import { VIP_BENEFITS } from "@/data/constants/vip-benefits"; import { PendingPaymentOrderStorage } from "@/data/storage"; import { usePaymentDispatch, usePaymentState, } from "@/stores/payment/payment-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { SubscriptionBackLink, SubscriptionBanner, SubscriptionBenefitsCard, SubscriptionCheckoutButton, SubscriptionCtaButton, SubscriptionPaymentMethod, 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"; export type SubscriptionType = "vip" | "voice"; const SUBSCRIPTION_BANNER_TITLES: Record = { vip: "Subscribe to become the VIP Member", voice: "Buy Voice Message Package", }; function isVipPlan(plan: PaymentPlan): boolean { return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_"); } function isVoicePackagePlan(plan: PaymentPlan): boolean { const orderType = plan.orderType.toLowerCase(); const planId = plan.planId.toLowerCase(); return ( plan.dolAmount !== null || orderType.startsWith("dol_") || planId.startsWith("dol_") || orderType.includes("voice") || planId.includes("voice") ); } function isPlanForType( plan: PaymentPlan, subscriptionType: SubscriptionType, ): boolean { return subscriptionType === "voice" ? isVoicePackagePlan(plan) : isVipPlan(plan); } 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, subscriptionType: SubscriptionType, ): string { if (subscriptionType === "voice") { return plan.dolAmount === null ? "Voice package" : `${plan.dolAmount} voice messages`; } 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, subscriptionType: SubscriptionType, ): SubscriptionPlanView { return { id: plan.planId, name: plan.planName, price: formatAmount(plan.amountCents), originalPrice: null, perDay: planCaption(plan, subscriptionType), currencySymbol: currencySymbol(plan.currency), }; } export interface SubscriptionScreenProps { subscriptionType?: SubscriptionType; shouldResumePendingOrder?: boolean; } export function SubscriptionScreen({ subscriptionType = "vip", shouldResumePendingOrder = false, }: SubscriptionScreenProps) { const user = useUserState(); const userDispatch = useUserDispatch(); const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); const refreshedPaidOrderRef = useRef(null); const resumedPendingOrderRef = useRef(null); useEffect(() => { if (payment.status === "idle") { paymentDispatch({ type: "PaymentInit" }); } }, [payment.status, paymentDispatch]); 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]); useEffect(() => { const canInspectPendingOrder = payment.status === "ready" || (!shouldResumePendingOrder && (payment.isPollingOrder || payment.isPaid || payment.status === "failed")); if (!canInspectPendingOrder) return; let cancelled = false; const handlePendingOrder = async () => { const result = await PendingPaymentOrderStorage.getPendingOrderForType( subscriptionType, ); if (cancelled || !result.success || result.data === null) return; if (!shouldResumePendingOrder) { await PendingPaymentOrderStorage.clearPendingOrder(); if ( payment.currentOrderId === result.data.orderId && (payment.isPollingOrder || payment.isPaid || payment.status === "failed") ) { paymentDispatch({ type: "PaymentReset" }); } return; } if (payment.currentOrderId === result.data.orderId) return; if (resumedPendingOrderRef.current === result.data.orderId) return; resumedPendingOrderRef.current = result.data.orderId; paymentDispatch({ type: "PaymentReturned", orderId: result.data.orderId, }); }; void handlePendingOrder(); return () => { cancelled = true; }; }, [ payment.currentOrderId, payment.isPaid, payment.isPollingOrder, payment.status, paymentDispatch, shouldResumePendingOrder, subscriptionType, ]); useEffect(() => { if (!payment.currentOrderId) return; if (!payment.isPaid && payment.status !== "failed") return; void PendingPaymentOrderStorage.clearPendingOrder(); }, [payment.currentOrderId, payment.isPaid, payment.status]); const plans = useMemo( () => payment.plans .filter((plan) => isPlanForType(plan, subscriptionType)) .map((plan) => toPlanView(plan, subscriptionType)), [payment.plans, subscriptionType], ); const selectedPlan = plans.find((plan) => plan.id === payment.selectedPlanId) ?? null; const isPaymentBusy = payment.isCreatingOrder || payment.isPollingOrder || payment.isCheckingVipStatus; const canActivate = selectedPlan !== null && payment.agreed && !isPaymentBusy; const isVip = subscriptionType === "vip" && (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"; const plansStatusMessage = subscriptionType === "voice" ? "Voice packages are unavailable." : "Membership plans are unavailable."; const plansAriaLabel = subscriptionType === "voice" ? "Choose a voice package" : "Choose a subscription plan"; useEffect(() => { if (payment.isLoadingPlans || payment.plans.length === 0) return; if (selectedPlan !== null) return; const nextPlan = payment.plans.find((plan) => isPlanForType(plan, subscriptionType), ); if (!nextPlan) return; paymentDispatch({ type: "PaymentPlanSelected", planId: nextPlan.planId, }); }, [ payment.isLoadingPlans, payment.plans, paymentDispatch, selectedPlan, subscriptionType, ]); return (
{plans.length > 0 ? ( plans.map((plan, index) => ( paymentDispatch({ type: "PaymentPlanSelected", planId: plan.id, }) } /> )) ) : (

{payment.isLoadingPlans ? "Loading membership plans..." : payment.errorMessage ?? plansStatusMessage}

)}

{autoRenewCaption}

{subscriptionType === "voice" ? (
{!isVip ? (
paymentDispatch({ type: "PaymentPayChannelChanged", payChannel, }) } />
) : null}
{isVip ? ( VIP Activated ) : ( )}
paymentDispatch({ type: "PaymentAgreementChanged", agreed, }) } label={ I agree to the{" "} VIP Membership Benefits Agreement {" "} and{" "} Automatic renewal Agreement } />
); }