Files
cozsweet-frontend-nextjs/src/app/subscription/subscription-screen.tsx
T

380 lines
11 KiB
TypeScript

"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<SubscriptionType, string> = {
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<string | null>(null);
const resumedPendingOrderRef = useRef<string | null>(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 (
<MobileShell>
<div className={styles.shell}>
<header className={styles.header}>
<SubscriptionBackLink className={styles.backSlot} />
</header>
<section className={styles.userSlot}>
<SubscriptionUserRow name={name} avatarUrl={avatarUrl} />
</section>
<section className={styles.bannerSlot}>
<SubscriptionBanner
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
/>
</section>
<section
className={styles.plansRow}
aria-label={plansAriaLabel}
>
{plans.length > 0 ? (
plans.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 ?? plansStatusMessage}
</p>
)}
</section>
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
<section className={styles.benefitsSlot}>
{subscriptionType === "voice" ? (
<div className={styles.benefitsPlaceholder} aria-hidden="true" />
) : (
<SubscriptionBenefitsCard
title="VIP Membership Benefits"
items={VIP_BENEFITS}
/>
)}
</section>
{!isVip ? (
<section className={styles.paymentMethodSlot}>
<SubscriptionPaymentMethod
value={payment.payChannel}
disabled={isPaymentBusy}
onChange={(payChannel) =>
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel,
})
}
/>
</section>
) : null}
<div className={styles.ctaSlot}>
{isVip ? (
<SubscriptionCtaButton type="button" disabled>
VIP Activated
</SubscriptionCtaButton>
) : (
<SubscriptionCheckoutButton
disabled={!canActivate}
subscriptionType={subscriptionType}
/>
)}
</div>
<div className={styles.agreementSlot}>
<Checkbox
checked={payment.agreed}
onChange={(agreed) =>
paymentDispatch({
type: "PaymentAgreementChanged",
agreed,
})
}
label={
<span className={styles.agreementLabel}>
I agree to the{" "}
<a
href={AppConstants.privacyPolicyUrl}
target="_blank"
rel="noreferrer"
className={styles.agreementLink}
>
VIP Membership Benefits Agreement
</a>{" "}
and{" "}
<a
href={AppConstants.termsOfServiceUrl}
target="_blank"
rel="noreferrer"
className={styles.agreementLink}
>
Automatic renewal Agreement
</a>
</span>
}
/>
</div>
</div>
</MobileShell>
);
}