380 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|