9e13b83171
- 原年付实付文案在模板字符串里再嵌一层 savingCents 三元,属于嵌套三元 - 把年付价格行提升为 annualPriceLine 常量,JSX 只保留 annual 单层分支,文案不变
1572 lines
49 KiB
TypeScript
1572 lines
49 KiB
TypeScript
import './index.css';
|
||
|
||
import QRCode from 'qrcode';
|
||
import type { ReactNode } from 'react';
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import type {
|
||
ProfileMembership,
|
||
ProfileMembershipCycleKind,
|
||
ProfileMembershipPlan,
|
||
ProfileMembershipPlanRecord,
|
||
ProfileMembershipUpgradeQuote,
|
||
ProfileRechargeCenterResponse,
|
||
ProfileRechargeProduct,
|
||
} from '../../contracts/runtime';
|
||
import { formatMudPointCount, formatRechargePrice } from '../../utils/format';
|
||
import {
|
||
formatMudPointResetAt,
|
||
resolveProfileMudPointDisplayTotal,
|
||
shouldShowProfileDailyFreePool,
|
||
} from '../../utils/mudPoints';
|
||
import {
|
||
resolveMembershipConcurrencyLabel,
|
||
resolveMembershipConcurrencyShortLabel,
|
||
resolveMembershipModelAccessLabel,
|
||
resolveMembershipPlanCopy,
|
||
} from './membershipCopy';
|
||
import { resolveMembershipProductId } from './membershipProduct';
|
||
|
||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||
|
||
/**
|
||
* roving tabindex / radiogroup 的方向键索引解析:`Home` / `End` 跳首尾,左右键循环,
|
||
* 其余按键返回 `null`(调用方据此忽略事件)。`currentIndex` 允许为 `-1`(未选中)。
|
||
*/
|
||
function resolveRovingIndex(
|
||
key: string,
|
||
currentIndex: number,
|
||
length: number,
|
||
): number | null {
|
||
if (length <= 0) {
|
||
return null;
|
||
}
|
||
switch (key) {
|
||
case 'Home':
|
||
return 0;
|
||
case 'End':
|
||
return length - 1;
|
||
case 'ArrowLeft':
|
||
return (currentIndex - 1 + length) % length;
|
||
case 'ArrowRight':
|
||
return (currentIndex + 1) % length;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type PlatformProfileRechargeTab = 'subscription' | 'recharge';
|
||
|
||
export type PlatformProfileRechargeNativePaymentState = {
|
||
orderId: string;
|
||
productTitle: string;
|
||
amountCents: number;
|
||
codeUrl: string;
|
||
expiresAt: string;
|
||
isConfirming: boolean;
|
||
confirmMessage?: string;
|
||
};
|
||
|
||
/** 弹层内的结算目标:泥点商品直接带商品行,会员只有「档位 + 周期」。 */
|
||
export type PlatformProfileRechargeCheckoutTarget =
|
||
| {
|
||
kind: 'points';
|
||
product: ProfileRechargeProduct;
|
||
}
|
||
| {
|
||
kind: 'membership';
|
||
plan: ProfileMembershipPlan;
|
||
cycleKind: ProfileMembershipCycleKind;
|
||
mode: 'purchase' | 'upgrade';
|
||
};
|
||
|
||
export type PlatformProfileRechargeModalProps = {
|
||
center: ProfileRechargeCenterResponse | null;
|
||
isLoading: boolean;
|
||
error: string | null;
|
||
submittingProductId: string | null;
|
||
nativePayment: PlatformProfileRechargeNativePaymentState | null;
|
||
/** 升级报价;仅升级确认页会用到,首次购买不走报价接口。 */
|
||
membershipQuote: ProfileMembershipUpgradeQuote | null;
|
||
isLoadingMembershipQuote: boolean;
|
||
membershipQuoteError: string | null;
|
||
onClose: () => void;
|
||
onRetry: () => void;
|
||
/** 泥点商品下单(确认页「充值」触发)。 */
|
||
onBuy: (product: ProfileRechargeProduct) => void;
|
||
/** 升级前取服务端报价;只有需要补差的档位才会调用。 */
|
||
onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void;
|
||
/** 会员下单;productId 由档位与周期拼出,复用泥点下单接口。 */
|
||
onSubmitMembershipCheckout: (
|
||
plan: ProfileMembershipPlan,
|
||
cycleKind: ProfileMembershipCycleKind,
|
||
) => void;
|
||
onConfirmNativePayment: () => void;
|
||
onCloseNativePayment: () => void;
|
||
};
|
||
|
||
function joinClassNames(
|
||
...classNames: Array<string | false | null | undefined>
|
||
) {
|
||
return classNames.filter(Boolean).join(' ');
|
||
}
|
||
|
||
function PlatformProfileRechargeModalShell({
|
||
title,
|
||
description,
|
||
onClose,
|
||
closeLabel,
|
||
closeDisabled = false,
|
||
size = 'sm',
|
||
zIndexClassName = 'z-[80]',
|
||
panelClassName,
|
||
bodyClassName = 'px-5 py-5',
|
||
children,
|
||
}: {
|
||
title: string;
|
||
description?: ReactNode;
|
||
onClose: () => void;
|
||
closeLabel?: string;
|
||
closeDisabled?: boolean;
|
||
size?: 'sm' | 'md';
|
||
zIndexClassName?: string;
|
||
panelClassName: string;
|
||
bodyClassName?: string;
|
||
children: ReactNode;
|
||
}) {
|
||
const titleId = useId();
|
||
const descriptionId = useId();
|
||
const sizeClassName = size === 'md' ? 'max-w-xl' : 'max-w-md';
|
||
|
||
return (
|
||
<div
|
||
className={joinClassNames(
|
||
'platform-profile-recharge-modal-backdrop fixed inset-0 flex items-center justify-center px-4 py-6',
|
||
zIndexClassName,
|
||
)}
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
aria-describedby={description ? descriptionId : undefined}
|
||
className={joinClassNames(
|
||
'platform-profile-recharge-modal flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-[1.75rem]',
|
||
sizeClassName,
|
||
panelClassName,
|
||
)}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-start justify-between gap-3 border-b border-white/10 px-5 py-4">
|
||
<div className="min-w-0">
|
||
<div
|
||
id={titleId}
|
||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||
>
|
||
{title}
|
||
</div>
|
||
{description ? (
|
||
<div
|
||
id={descriptionId}
|
||
className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]"
|
||
>
|
||
{description}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label={closeLabel ?? `关闭${title}`}
|
||
title={closeLabel ?? `关闭${title}`}
|
||
disabled={closeDisabled}
|
||
onClick={onClose}
|
||
className="platform-profile-recharge-modal__close flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg font-black disabled:cursor-not-allowed disabled:opacity-45"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={joinClassNames('min-h-0 flex-1', bodyClassName)}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformRechargeActionButton({
|
||
children,
|
||
className,
|
||
disabled,
|
||
ariaLabel,
|
||
onClick,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
disabled?: boolean;
|
||
ariaLabel?: string;
|
||
onClick?: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-label={ariaLabel}
|
||
className={joinClassNames(
|
||
'platform-profile-recharge-modal__primary-button inline-flex items-center justify-center gap-2 rounded-2xl px-4 py-2 text-xs font-black',
|
||
className,
|
||
)}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformRechargeAsyncStatePanel({
|
||
errorState,
|
||
isLoading,
|
||
loadingState,
|
||
isEmpty,
|
||
emptyState,
|
||
children,
|
||
}: {
|
||
errorState?: ReactNode;
|
||
isLoading?: boolean;
|
||
loadingState?: ReactNode;
|
||
isEmpty?: boolean;
|
||
emptyState?: ReactNode;
|
||
children: ReactNode;
|
||
}) {
|
||
if (errorState !== undefined && errorState !== null) {
|
||
return <>{errorState}</>;
|
||
}
|
||
if (isLoading) {
|
||
return <>{loadingState}</>;
|
||
}
|
||
if (isEmpty) {
|
||
return <>{emptyState}</>;
|
||
}
|
||
return <>{children}</>;
|
||
}
|
||
|
||
function PlatformRechargeSkeletonList({
|
||
count,
|
||
containerClassName,
|
||
itemClassName,
|
||
}: {
|
||
count: number;
|
||
containerClassName?: string;
|
||
itemClassName?: string;
|
||
}) {
|
||
return (
|
||
<div className={containerClassName}>
|
||
{Array.from({ length: count }).map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className={joinClassNames(
|
||
'animate-pulse rounded-xl bg-zinc-100',
|
||
itemClassName,
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformRechargeEmptyState({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={joinClassNames(
|
||
'platform-profile-recharge-modal__empty min-w-0 rounded-[1rem] px-4 py-5 text-center text-sm font-semibold',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformRechargeStatusMessage({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={joinClassNames(
|
||
'platform-profile-recharge-modal__status-error rounded-xl px-3 py-2 text-xs',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function useWechatNativeQrCode(codeUrl: string | null) {
|
||
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setQrImageUrl(null);
|
||
if (!codeUrl) {
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
void QRCode.toDataURL(codeUrl, {
|
||
errorCorrectionLevel: 'M',
|
||
margin: 1,
|
||
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
|
||
}).then((dataUrl) => {
|
||
if (!cancelled) {
|
||
setQrImageUrl(dataUrl);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [codeUrl]);
|
||
|
||
return qrImageUrl;
|
||
}
|
||
|
||
function formatNativePaymentRemaining(remainingMs: number) {
|
||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
function useNativePaymentRemaining(expiresAt: string) {
|
||
const resolveRemainingMs = useCallback(() => {
|
||
const expiresAtMs = Date.parse(expiresAt);
|
||
if (!Number.isFinite(expiresAtMs)) {
|
||
return 0;
|
||
}
|
||
return Math.max(0, expiresAtMs - Date.now());
|
||
}, [expiresAt]);
|
||
const [remainingMs, setRemainingMs] = useState(resolveRemainingMs);
|
||
|
||
useEffect(() => {
|
||
setRemainingMs(resolveRemainingMs());
|
||
const timer = window.setInterval(() => {
|
||
setRemainingMs(resolveRemainingMs());
|
||
}, 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, [resolveRemainingMs]);
|
||
|
||
return {
|
||
isExpired: remainingMs <= 0,
|
||
label: formatNativePaymentRemaining(remainingMs),
|
||
};
|
||
}
|
||
|
||
type MembershipPlanAction = {
|
||
disabled: boolean;
|
||
label: string;
|
||
mode: 'purchase' | 'upgrade';
|
||
};
|
||
|
||
function resolveMembershipPlanAction(
|
||
plan: ProfileMembershipPlanRecord,
|
||
membership: ProfileMembership | null,
|
||
currentPlan: ProfileMembershipPlanRecord | null,
|
||
): MembershipPlanAction {
|
||
const isActive =
|
||
membership?.status === 'active' && membership.plan !== 'normal';
|
||
if (!isActive || !membership || !currentPlan) {
|
||
return { disabled: false, label: '开通会员', mode: 'purchase' };
|
||
}
|
||
if (plan.plan === membership.plan) {
|
||
return { disabled: true, label: '当前套餐', mode: 'purchase' };
|
||
}
|
||
if (plan.rank <= currentPlan.rank) {
|
||
return { disabled: true, label: '不支持降档', mode: 'purchase' };
|
||
}
|
||
return { disabled: false, label: '补差升级', mode: 'upgrade' };
|
||
}
|
||
|
||
/** 升级报价的加载 / 失败态;非升级流程不展示报价状态。 */
|
||
function resolveMembershipQuoteErrorState({
|
||
isUpgrade,
|
||
error,
|
||
isLoading,
|
||
onRetry,
|
||
}: {
|
||
isUpgrade: boolean;
|
||
error: string | null;
|
||
isLoading: boolean;
|
||
onRetry: () => void;
|
||
}): ReactNode {
|
||
if (!isUpgrade) {
|
||
return null;
|
||
}
|
||
if (error) {
|
||
return (
|
||
<PlatformRechargeStatusMessage className="rounded-2xl font-semibold">
|
||
<div>{error}</div>
|
||
<PlatformRechargeActionButton className="mt-3" onClick={onRetry}>
|
||
重新获取报价
|
||
</PlatformRechargeActionButton>
|
||
</PlatformRechargeStatusMessage>
|
||
);
|
||
}
|
||
if (isLoading) {
|
||
return (
|
||
<div className="rounded-xl px-3 py-2 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
正在获取升级报价…
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function PlatformMudPointPoolOverview({
|
||
center,
|
||
}: {
|
||
center: ProfileRechargeCenterResponse | null;
|
||
}) {
|
||
const balance = center?.mudPointBalance;
|
||
const showDailyFree = shouldShowProfileDailyFreePool(balance);
|
||
const dailyFreeResetsAt = formatMudPointResetAt(balance?.dailyFreeResetsAt);
|
||
const monthlyResetsAt = formatMudPointResetAt(balance?.monthlyResetsAt);
|
||
const pools = [
|
||
...(showDailyFree
|
||
? [
|
||
{
|
||
key: 'daily',
|
||
label: '每日免费',
|
||
points: balance?.dailyFreePoints ?? 0,
|
||
detail: `每天重置为 ${formatMudPointCount(balance?.dailyFreeResetPoints ?? 0)}`,
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
key: 'monthly',
|
||
label: '月度泥点',
|
||
points: balance?.monthlyPoints ?? 0,
|
||
detail: monthlyResetsAt
|
||
? `${monthlyResetsAt} 刷新`
|
||
: '会员每期额度,刷新日清零',
|
||
},
|
||
{
|
||
key: 'permanent',
|
||
label: '永久泥点',
|
||
points: balance?.permanentPoints ?? 0,
|
||
detail: '不过期,不随会员到期清零',
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="grid gap-2">
|
||
<div className="rounded-[1rem] border border-[rgba(194,145,111,0.28)] bg-white/45 px-4 py-3">
|
||
<div className="text-[11px] font-bold text-[var(--platform-text-soft)]">
|
||
可用总额
|
||
</div>
|
||
<div className="mt-0.5 text-xl font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{formatMudPointCount(
|
||
resolveProfileMudPointDisplayTotal(balance) ?? 0,
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={`grid ${showDailyFree ? 'grid-cols-3' : 'grid-cols-2'} gap-2`}
|
||
>
|
||
{pools.map((pool) => (
|
||
<div
|
||
key={pool.key}
|
||
className="min-w-0 rounded-[1rem] border border-[rgba(194,145,111,0.28)] bg-white/35 px-3 py-2"
|
||
>
|
||
<div className="truncate text-[11px] font-bold text-[var(--platform-text-soft)]">
|
||
{pool.label}
|
||
</div>
|
||
<div className="mt-0.5 text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{formatMudPointCount(pool.points)}
|
||
</div>
|
||
<div className="mt-0.5 truncate text-[10px] text-[var(--platform-text-soft)]">
|
||
{pool.detail}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{showDailyFree && dailyFreeResetsAt ? (
|
||
<p className="text-[10px] text-[var(--platform-text-soft)]">
|
||
创作扣点顺序:每日免费 → 月度 → 永久,{dailyFreeResetsAt}{' '}
|
||
重置每日免费额度。
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformRechargeTabNav({
|
||
activeTab,
|
||
onChange,
|
||
}: {
|
||
activeTab: PlatformProfileRechargeTab;
|
||
onChange: (tab: PlatformProfileRechargeTab) => void;
|
||
}) {
|
||
const tabs: Array<{ id: PlatformProfileRechargeTab; label: string }> = [
|
||
{ id: 'subscription', label: '会员订阅' },
|
||
{ id: 'recharge', label: '泥点充值' },
|
||
];
|
||
// 方向键移动选中项后把焦点一并移到新页签,保持 roving tabindex 的焦点与选中一致。
|
||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||
|
||
return (
|
||
<div
|
||
role="tablist"
|
||
aria-label="会员与泥点"
|
||
className="platform-subpanel flex gap-1 rounded-full p-1"
|
||
onKeyDown={(event) => {
|
||
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab);
|
||
const nextIndex = resolveRovingIndex(
|
||
event.key,
|
||
currentIndex,
|
||
tabs.length,
|
||
);
|
||
if (nextIndex === null) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const nextTab = tabs[nextIndex];
|
||
if (nextTab) {
|
||
onChange(nextTab.id);
|
||
tabRefs.current[nextTab.id]?.focus();
|
||
}
|
||
}}
|
||
>
|
||
{tabs.map((tab) => {
|
||
const selected = tab.id === activeTab;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
ref={(node) => {
|
||
tabRefs.current[tab.id] = node;
|
||
}}
|
||
type="button"
|
||
role="tab"
|
||
id={`platform-recharge-tab-${tab.id}`}
|
||
aria-selected={selected}
|
||
aria-controls={`platform-recharge-panel-${tab.id}`}
|
||
tabIndex={selected ? 0 : -1}
|
||
onClick={() => onChange(tab.id)}
|
||
className={joinClassNames(
|
||
'flex-1 rounded-full px-3 py-2 text-xs font-black transition',
|
||
selected
|
||
? 'platform-profile-recharge-modal__primary-button'
|
||
: 'text-[var(--platform-text-soft)] hover:bg-white/55',
|
||
)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformMembershipStatusCard({
|
||
membership,
|
||
currentPlan,
|
||
}: {
|
||
membership: ProfileMembership;
|
||
currentPlan: ProfileMembershipPlanRecord | null;
|
||
}) {
|
||
const planTitle = currentPlan?.title ?? '会员';
|
||
const cycleLabel = membership.cycleKind === 'yearly' ? '年付' : '月付';
|
||
const expiresAt = formatMudPointResetAt(membership.expiresAt);
|
||
const cycleIndexLabel = `${Math.max(1, membership.cycleIndex)}/${Math.max(
|
||
1,
|
||
membership.cycleCount,
|
||
)}`;
|
||
|
||
return (
|
||
<div className="platform-subpanel flex items-center justify-between gap-3 rounded-[1rem] px-4 py-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[13px] font-black text-[var(--platform-text-strong)]">
|
||
当前 {planTitle} · {cycleLabel}
|
||
</div>
|
||
<div className="mt-0.5 truncate text-[11px] text-[var(--platform-text-soft)]">
|
||
{expiresAt ? `会员至 ${expiresAt}` : '会员有效期读取中'}
|
||
{membership.cycleKind === 'yearly'
|
||
? ` · 第 ${cycleIndexLabel} 期`
|
||
: ''}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right text-[10px] font-semibold text-[var(--platform-text-soft)]">
|
||
可同周期补差升档
|
||
<br />
|
||
周期切换需等到期后
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformMembershipCycleSwitch({
|
||
cycleKind,
|
||
locked,
|
||
onChange,
|
||
}: {
|
||
cycleKind: ProfileMembershipCycleKind;
|
||
locked: boolean;
|
||
onChange: (cycleKind: ProfileMembershipCycleKind) => void;
|
||
}) {
|
||
const options: Array<{ id: ProfileMembershipCycleKind; label: string }> = [
|
||
{ id: 'monthly', label: '月付' },
|
||
{ id: 'yearly', label: '年付' },
|
||
];
|
||
|
||
return (
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="付费周期"
|
||
className="platform-subpanel flex gap-1 rounded-full p-1"
|
||
>
|
||
{options.map((option) => {
|
||
const selected = option.id === cycleKind;
|
||
return (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={selected}
|
||
disabled={locked && !selected}
|
||
onClick={() => onChange(option.id)}
|
||
className={joinClassNames(
|
||
'rounded-full px-3 py-1.5 text-xs font-black transition disabled:cursor-not-allowed disabled:opacity-45',
|
||
selected
|
||
? 'platform-profile-recharge-modal__primary-button'
|
||
: 'text-[var(--platform-text-soft)] hover:bg-white/55',
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{locked ? (
|
||
<span className="text-[10px] font-semibold text-[var(--platform-text-soft)]">
|
||
有效期内不支持月年互换
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformMembershipPlanCard({
|
||
plan,
|
||
cycleKind,
|
||
isCurrentPlan,
|
||
recommended,
|
||
actionLabel,
|
||
disabled,
|
||
submitting,
|
||
onCheckout,
|
||
}: {
|
||
plan: ProfileMembershipPlanRecord;
|
||
cycleKind: ProfileMembershipCycleKind;
|
||
isCurrentPlan: boolean;
|
||
recommended: boolean;
|
||
actionLabel: string;
|
||
disabled: boolean;
|
||
submitting: boolean;
|
||
onCheckout: () => void;
|
||
}) {
|
||
const copy = resolveMembershipPlanCopy(plan.plan);
|
||
const annual = cycleKind === 'yearly';
|
||
const listPriceCents = annual ? plan.yearPriceCents : plan.monthPriceCents;
|
||
const perMonthCents = annual
|
||
? Math.round(plan.yearPriceCents / 12)
|
||
: plan.monthPriceCents;
|
||
const savingCents = annual
|
||
? Math.max(0, plan.monthPriceCents * 12 - plan.yearPriceCents)
|
||
: 0;
|
||
const annualPriceLine = `年实付 ${formatRechargePrice(plan.yearPriceCents)}${
|
||
savingCents > 0 ? ` · 省 ${formatRechargePrice(savingCents)}` : ''
|
||
}`;
|
||
// 按钮文案优先级:不可购买时展示业务文案,其次「处理中」,最后默认「选择并确认」。
|
||
let actionButtonLabel = '选择并确认';
|
||
if (disabled) {
|
||
actionButtonLabel = actionLabel;
|
||
} else if (submitting) {
|
||
actionButtonLabel = '处理中';
|
||
}
|
||
|
||
return (
|
||
<article
|
||
className={joinClassNames(
|
||
'platform-subpanel platform-profile-recharge-modal__interactive-card relative flex min-w-0 flex-col gap-2 rounded-[1.15rem] px-4 py-4',
|
||
isCurrentPlan ? 'ring-2 ring-[var(--platform-warm-border)]' : '',
|
||
)}
|
||
>
|
||
{recommended ? (
|
||
<span className="platform-profile-recharge-modal__badge-warning absolute right-3 top-3 rounded-full border px-2 py-0.5 text-[10px] font-black">
|
||
推荐之选
|
||
</span>
|
||
) : null}
|
||
<div className="min-w-0">
|
||
<h3 className="truncate text-sm font-black text-[var(--platform-text-strong)]">
|
||
{plan.title}
|
||
</h3>
|
||
<p className="mt-0.5 text-[11px] text-[var(--platform-text-soft)]">
|
||
{copy.estimateAlt}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-baseline gap-1">
|
||
<span className="text-lg font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{formatRechargePrice(perMonthCents)}
|
||
</span>
|
||
<span className="text-[11px] font-bold text-[var(--platform-text-soft)]">
|
||
{annual ? '折合 / 月' : '/ 月'}
|
||
</span>
|
||
</div>
|
||
<div className="text-[10px] text-[var(--platform-text-soft)]">
|
||
{annual
|
||
? annualPriceLine
|
||
: `每次支付 ${formatRechargePrice(listPriceCents)},有效期 1 个月`}
|
||
</div>
|
||
<div className="rounded-[0.85rem] border border-[rgba(194,145,111,0.28)] bg-white/35 px-3 py-2">
|
||
<div className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{formatMudPointCount(plan.periodPoints)} 月度泥点
|
||
</div>
|
||
<div className="mt-0.5 text-[10px] text-[var(--platform-text-soft)]">
|
||
{annual ? '每月刷新 · 全年发放 12 期' : '每月刷新 · 当期有效'}
|
||
</div>
|
||
</div>
|
||
<div className="text-[10px] leading-5 text-[var(--platform-text-soft)]">
|
||
每月预计可完成约 {copy.estimateCount} · {copy.estimateKind}
|
||
</div>
|
||
<ul className="grid gap-0.5 text-[10px] leading-5 text-[var(--platform-text-soft)]">
|
||
<li>{resolveMembershipModelAccessLabel(plan.modelAccess)}</li>
|
||
<li>{resolveMembershipConcurrencyLabel(plan)}</li>
|
||
</ul>
|
||
<PlatformRechargeActionButton
|
||
ariaLabel={`${plan.title} ${cycleKind === 'yearly' ? '年付' : '月付'} ${actionLabel}`}
|
||
className="mt-1 w-full disabled:cursor-not-allowed"
|
||
disabled={disabled || submitting}
|
||
onClick={onCheckout}
|
||
>
|
||
{actionButtonLabel}
|
||
</PlatformRechargeActionButton>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function PlatformMembershipComparisonTable({
|
||
plans,
|
||
}: {
|
||
plans: ProfileMembershipPlanRecord[];
|
||
}) {
|
||
const rows: Array<{
|
||
label: string;
|
||
values: string[];
|
||
}> = [
|
||
{
|
||
label: '月付价格',
|
||
values: plans.map((plan) => formatRechargePrice(plan.monthPriceCents)),
|
||
},
|
||
{
|
||
label: '年付价格',
|
||
values: plans.map((plan) => formatRechargePrice(plan.yearPriceCents)),
|
||
},
|
||
{
|
||
label: '每期泥点',
|
||
values: plans.map((plan) => formatMudPointCount(plan.periodPoints)),
|
||
},
|
||
{
|
||
label: '模型权限',
|
||
values: plans.map((plan) =>
|
||
resolveMembershipModelAccessLabel(plan.modelAccess),
|
||
),
|
||
},
|
||
{
|
||
label: '独立任务并发',
|
||
values: plans.map((plan) => resolveMembershipConcurrencyShortLabel(plan)),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[26rem] border-collapse text-left text-[11px]">
|
||
<thead>
|
||
<tr>
|
||
<th
|
||
scope="col"
|
||
className="p-2 font-black text-[var(--platform-text-soft)]"
|
||
>
|
||
套餐
|
||
</th>
|
||
{plans.map((plan) => (
|
||
<th
|
||
key={plan.plan}
|
||
scope="col"
|
||
className="p-2 font-black text-[var(--platform-text-strong)]"
|
||
>
|
||
{plan.title}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row) => (
|
||
<tr
|
||
key={row.label}
|
||
className="border-t border-[rgba(194,145,111,0.28)]"
|
||
>
|
||
<th
|
||
scope="row"
|
||
className="p-2 font-bold text-[var(--platform-text-soft)]"
|
||
>
|
||
{row.label}
|
||
</th>
|
||
{row.values.map((value, index) => (
|
||
<td
|
||
key={`${row.label}-${plans[index]?.plan ?? index}`}
|
||
className="p-2 tabular-nums text-[var(--platform-text-strong)]"
|
||
>
|
||
{value}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformPointProductGrid({
|
||
products,
|
||
selectedProductId,
|
||
submittingProductId,
|
||
onSelect,
|
||
}: {
|
||
products: ProfileRechargeProduct[];
|
||
selectedProductId: string | null;
|
||
submittingProductId: string | null;
|
||
onSelect: (product: ProfileRechargeProduct) => void;
|
||
}) {
|
||
// 方向键移动选中项后同步移动焦点,保持 roving tabindex 的焦点与选中一致。
|
||
const productRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||
return (
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="泥点充值档位"
|
||
className="grid grid-cols-2 gap-2 sm:grid-cols-3"
|
||
onKeyDown={(event) => {
|
||
const currentIndex = products.findIndex(
|
||
(product) => product.productId === selectedProductId,
|
||
);
|
||
const nextIndex = resolveRovingIndex(
|
||
event.key,
|
||
currentIndex,
|
||
products.length,
|
||
);
|
||
if (nextIndex === null) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const nextProduct = products[nextIndex];
|
||
if (nextProduct) {
|
||
onSelect(nextProduct);
|
||
productRefs.current[nextProduct.productId]?.focus();
|
||
}
|
||
}}
|
||
>
|
||
{products.map((product) => {
|
||
const selected = product.productId === selectedProductId;
|
||
const submitting = submittingProductId === product.productId;
|
||
return (
|
||
<button
|
||
key={product.productId}
|
||
ref={(node) => {
|
||
productRefs.current[product.productId] = node;
|
||
}}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={selected}
|
||
tabIndex={selected ? 0 : -1}
|
||
disabled={Boolean(submittingProductId)}
|
||
onClick={() => onSelect(product)}
|
||
aria-label={`${formatMudPointCount(product.pointsAmount)}泥点 ${formatRechargePrice(product.priceCents)}`}
|
||
className={joinClassNames(
|
||
'platform-subpanel platform-profile-recharge-modal__interactive-card flex min-w-0 flex-col items-start gap-1 rounded-[1rem] px-3 py-3 text-left disabled:cursor-not-allowed disabled:opacity-55',
|
||
selected ? 'ring-2 ring-[var(--platform-warm-border)]' : '',
|
||
)}
|
||
>
|
||
<span className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{formatMudPointCount(product.pointsAmount)}
|
||
<small className="ml-1 text-[10px] font-bold text-[var(--platform-text-soft)]">
|
||
永久泥点
|
||
</small>
|
||
</span>
|
||
<span className="text-xs font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{formatRechargePrice(product.priceCents)}
|
||
</span>
|
||
<span className="text-[10px] text-[var(--platform-text-soft)]">
|
||
{submitting ? '处理中' : product.badgeLabel || '永久有效'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformRechargeCheckoutDialog({
|
||
target,
|
||
membershipPlans,
|
||
membership,
|
||
membershipQuote,
|
||
isLoadingMembershipQuote,
|
||
membershipQuoteError,
|
||
submittingProductId,
|
||
showDailyFreePool,
|
||
onClose,
|
||
onBuy,
|
||
onRequestMembershipQuote,
|
||
onSubmitMembershipCheckout,
|
||
}: {
|
||
target: PlatformProfileRechargeCheckoutTarget;
|
||
membershipPlans: ProfileMembershipPlanRecord[];
|
||
membership: ProfileMembership | null;
|
||
membershipQuote: ProfileMembershipUpgradeQuote | null;
|
||
isLoadingMembershipQuote: boolean;
|
||
membershipQuoteError: string | null;
|
||
submittingProductId: string | null;
|
||
showDailyFreePool: boolean;
|
||
onClose: () => void;
|
||
onBuy: (product: ProfileRechargeProduct) => void;
|
||
onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void;
|
||
onSubmitMembershipCheckout: (
|
||
plan: ProfileMembershipPlan,
|
||
cycleKind: ProfileMembershipCycleKind,
|
||
) => void;
|
||
}) {
|
||
if (target.kind === 'points') {
|
||
const product = target.product;
|
||
const submitting = submittingProductId === product.productId;
|
||
return (
|
||
<PlatformRechargeCheckoutShell
|
||
title="确认订单"
|
||
submitting={submitting}
|
||
amountCents={product.priceCents}
|
||
ctaLabel={`充值 ${formatRechargePrice(product.priceCents)}`}
|
||
onClose={onClose}
|
||
onSubmit={() => {
|
||
onBuy(product);
|
||
onClose();
|
||
}}
|
||
lines={[
|
||
{ label: '充值项目', value: product.title },
|
||
{
|
||
label: '本次到账',
|
||
value: `${formatMudPointCount(product.pointsAmount)} 永久泥点`,
|
||
},
|
||
]}
|
||
note={
|
||
showDailyFreePool
|
||
? '永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。'
|
||
: '永久有效,不会随会员到期清零。创作时先扣月度泥点,不足部分再扣永久泥点。'
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const plan =
|
||
membershipPlans.find((item) => item.plan === target.plan) ?? null;
|
||
const annual = target.cycleKind === 'yearly';
|
||
const cycleLabel = annual ? '年付' : '月付';
|
||
const listPriceCents = annual
|
||
? (plan?.yearPriceCents ?? 0)
|
||
: (plan?.monthPriceCents ?? 0);
|
||
const isUpgrade = target.mode === 'upgrade';
|
||
const currentPlan =
|
||
membershipPlans.find((item) => item.plan === membership?.plan) ?? null;
|
||
const productId = resolveMembershipProductId(target.plan, target.cycleKind);
|
||
const submitting = submittingProductId === productId;
|
||
const quoteForTarget =
|
||
isUpgrade &&
|
||
membershipQuote &&
|
||
membershipQuote.targetPlan === target.plan &&
|
||
membershipQuote.cycleKind === target.cycleKind
|
||
? membershipQuote
|
||
: null;
|
||
// 升级金额只能来自服务端报价:报价未到 / 失败时不能拿新套餐标准价冒充「本次补付」。
|
||
const amountPending = isUpgrade && !quoteForTarget;
|
||
const amountCents = quoteForTarget
|
||
? quoteForTarget.amountCents
|
||
: listPriceCents;
|
||
const amountText = amountPending ? '—' : formatRechargePrice(amountCents);
|
||
let ctaLabel: string;
|
||
if (!isUpgrade) {
|
||
ctaLabel = `支付 ${amountText}`;
|
||
} else if (amountPending) {
|
||
ctaLabel = '补差升级';
|
||
} else {
|
||
ctaLabel = `补差升级 ${amountText}`;
|
||
}
|
||
const canSubmit =
|
||
Boolean(plan) &&
|
||
!submitting &&
|
||
(!isUpgrade || Boolean(quoteForTarget)) &&
|
||
amountCents > 0;
|
||
|
||
const lines: Array<{ label: string; value: string }> = [];
|
||
if (isUpgrade) {
|
||
if (currentPlan) {
|
||
lines.push({
|
||
label: `原套餐标准${annual ? '年' : '月'}价`,
|
||
value: formatRechargePrice(
|
||
annual ? currentPlan.yearPriceCents : currentPlan.monthPriceCents,
|
||
),
|
||
});
|
||
}
|
||
lines.push({
|
||
label: `新套餐标准${annual ? '年' : '月'}价`,
|
||
value: formatRechargePrice(listPriceCents),
|
||
});
|
||
if (quoteForTarget) {
|
||
lines.push({
|
||
label: '本期补发月度泥点',
|
||
value: `+${formatMudPointCount(quoteForTarget.grantedPointsDelta)}`,
|
||
});
|
||
lines.push({
|
||
label: '补点后月度余额',
|
||
value: formatMudPointCount(quoteForTarget.monthlyBalanceAfter),
|
||
});
|
||
const expiresAt = formatMudPointResetAt(quoteForTarget.expiresAt);
|
||
const cycleResetsAt = formatMudPointResetAt(quoteForTarget.cycleResetsAt);
|
||
if (expiresAt) {
|
||
lines.push({ label: '会员到期日(不变)', value: expiresAt });
|
||
}
|
||
if (cycleResetsAt) {
|
||
lines.push({ label: '本期刷新日(不变)', value: cycleResetsAt });
|
||
}
|
||
}
|
||
} else {
|
||
lines.push({
|
||
label: '到账月度泥点',
|
||
value: plan ? `${formatMudPointCount(plan.periodPoints)}(首期)` : '—',
|
||
});
|
||
lines.push({
|
||
label: '计费周期',
|
||
value: annual
|
||
? '年付 · 共 12 期,本次只到账首期'
|
||
: '月付 · 本次到账当期额度',
|
||
});
|
||
}
|
||
|
||
return (
|
||
<PlatformRechargeCheckoutShell
|
||
title={isUpgrade ? '确认套餐变更' : '确认订单'}
|
||
submitting={submitting}
|
||
amountCents={amountCents}
|
||
amountPending={amountPending}
|
||
amountLabel={isUpgrade ? '本次补付' : '本次支付'}
|
||
ctaLabel={ctaLabel}
|
||
ctaDisabled={!canSubmit}
|
||
onClose={onClose}
|
||
onSubmit={() => {
|
||
onSubmitMembershipCheckout(target.plan, target.cycleKind);
|
||
onClose();
|
||
}}
|
||
lines={lines}
|
||
note={
|
||
isUpgrade
|
||
? '按剩余比例补差,保留现有余额,已用点不返还;升级后原刷新日与到期日不变。'
|
||
: `月度泥点按开通日自然月计期,当期未用完部分到期清零,不结转。当前选择 ${cycleLabel}。`
|
||
}
|
||
errorState={resolveMembershipQuoteErrorState({
|
||
isUpgrade,
|
||
error: membershipQuoteError,
|
||
isLoading: isLoadingMembershipQuote,
|
||
onRetry: () => onRequestMembershipQuote(target.plan),
|
||
})}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function PlatformRechargeCheckoutShell({
|
||
title,
|
||
amountCents,
|
||
amountPending = false,
|
||
amountLabel = '本次支付',
|
||
ctaLabel,
|
||
ctaDisabled = false,
|
||
submitting,
|
||
lines,
|
||
note,
|
||
errorState,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
title: string;
|
||
amountCents: number;
|
||
/** 金额未知(升级报价未到 / 失败)时只显示占位符,避免拿标准价冒充补差金额。 */
|
||
amountPending?: boolean;
|
||
amountLabel?: string;
|
||
ctaLabel: string;
|
||
ctaDisabled?: boolean;
|
||
submitting: boolean;
|
||
lines: Array<{ label: string; value: string }>;
|
||
note: string;
|
||
errorState?: ReactNode;
|
||
onClose: () => void;
|
||
onSubmit: () => void;
|
||
}) {
|
||
return (
|
||
<PlatformProfileRechargeModalShell
|
||
title={title}
|
||
description="确认后进入微信支付"
|
||
onClose={onClose}
|
||
closeLabel={`关闭${title}`}
|
||
closeDisabled={submitting}
|
||
size="sm"
|
||
zIndexClassName="z-[86]"
|
||
panelClassName="!max-w-sm rounded-[1.4rem]"
|
||
bodyClassName="px-5 py-5"
|
||
>
|
||
<div className="text-center">
|
||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||
{amountLabel}
|
||
</div>
|
||
<div className="mt-1 text-3xl font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{amountPending ? '—' : formatRechargePrice(amountCents)}
|
||
</div>
|
||
</div>
|
||
{errorState ? <div className="mt-4">{errorState}</div> : null}
|
||
<div className="mt-4 grid gap-1.5 rounded-2xl border border-white/10 bg-white/6 px-4 py-3">
|
||
{lines.map((line) => (
|
||
<div
|
||
key={line.label}
|
||
className="flex items-start justify-between gap-3 text-xs"
|
||
>
|
||
<span className="font-bold text-[var(--platform-text-soft)]">
|
||
{line.label}
|
||
</span>
|
||
<strong className="text-right font-black text-[var(--platform-text-strong)]">
|
||
{line.value}
|
||
</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-4 text-[11px] font-bold text-[var(--platform-text-soft)]">
|
||
支付方式
|
||
</div>
|
||
{/* 中文注释:当前只支持微信支付,是纯展示文案,不用 radiogroup / radio 语义(不可交互的
|
||
单选项会误导读屏)。 */}
|
||
<div className="mt-1.5 flex items-center gap-2 rounded-2xl border border-white/10 bg-white/6 px-3 py-2">
|
||
<span className="inline-flex items-center gap-2 text-xs font-black text-[var(--platform-text-strong)]">
|
||
<span className="platform-profile-recharge-modal__badge-warning inline-flex h-5 w-5 items-center justify-center rounded-full border text-[10px]">
|
||
微
|
||
</span>
|
||
微信支付
|
||
</span>
|
||
</div>
|
||
<PlatformRechargeActionButton
|
||
className="mt-4 w-full disabled:cursor-not-allowed"
|
||
disabled={submitting || ctaDisabled}
|
||
onClick={onSubmit}
|
||
>
|
||
{submitting ? '处理中' : ctaLabel}
|
||
</PlatformRechargeActionButton>
|
||
<p className="mt-3 text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||
{note}
|
||
</p>
|
||
</PlatformProfileRechargeModalShell>
|
||
);
|
||
}
|
||
|
||
function PlatformProfileWechatNativePaymentModal({
|
||
nativePayment,
|
||
nativeQrImageUrl,
|
||
onClose,
|
||
onConfirm,
|
||
}: {
|
||
nativePayment: PlatformProfileRechargeNativePaymentState;
|
||
nativeQrImageUrl: string | null;
|
||
onClose: () => void;
|
||
onConfirm: () => void;
|
||
}) {
|
||
const remaining = useNativePaymentRemaining(nativePayment.expiresAt);
|
||
|
||
return (
|
||
<PlatformProfileRechargeModalShell
|
||
title="微信扫码支付"
|
||
description="请使用微信扫描二维码完成付款"
|
||
onClose={onClose}
|
||
closeLabel="关闭微信扫码支付"
|
||
closeDisabled={nativePayment.isConfirming}
|
||
size="sm"
|
||
zIndexClassName="z-[85]"
|
||
panelClassName="!max-w-sm rounded-[1.4rem]"
|
||
bodyClassName="px-5 py-5"
|
||
>
|
||
<div className="text-center">
|
||
<div className="mb-4 rounded-2xl border border-white/10 bg-white/6 px-4 py-3 text-left">
|
||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||
支付项目
|
||
</div>
|
||
<div className="mt-1 text-sm font-black text-[var(--platform-text-strong)]">
|
||
{nativePayment.productTitle}
|
||
</div>
|
||
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
|
||
支付金额
|
||
</div>
|
||
<div className="mt-1 text-2xl font-black text-[var(--platform-text-strong)]">
|
||
{formatRechargePrice(nativePayment.amountCents)}
|
||
</div>
|
||
<div className="mt-3 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
|
||
<span>剩余时间</span>
|
||
<span className="tabular-nums">
|
||
{remaining.isExpired ? '已过期' : remaining.label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="mx-auto flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
|
||
{nativeQrImageUrl ? (
|
||
<img
|
||
src={nativeQrImageUrl}
|
||
alt="微信 Native 支付二维码"
|
||
className="h-full w-full"
|
||
/>
|
||
) : (
|
||
<span className="text-xs font-semibold text-slate-500">生成中</span>
|
||
)}
|
||
</div>
|
||
<PlatformRechargeActionButton
|
||
className="mt-4 disabled:cursor-not-allowed"
|
||
onClick={onConfirm}
|
||
disabled={nativePayment.isConfirming || remaining.isExpired}
|
||
>
|
||
{nativePayment.isConfirming
|
||
? '确认中'
|
||
: remaining.isExpired
|
||
? '已过期'
|
||
: '我已支付'}
|
||
</PlatformRechargeActionButton>
|
||
{nativePayment.confirmMessage ? (
|
||
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
{nativePayment.confirmMessage}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</PlatformProfileRechargeModalShell>
|
||
);
|
||
}
|
||
|
||
export function PlatformProfileRechargeModal({
|
||
center,
|
||
isLoading,
|
||
error,
|
||
submittingProductId,
|
||
nativePayment,
|
||
membershipQuote,
|
||
isLoadingMembershipQuote,
|
||
membershipQuoteError,
|
||
onClose,
|
||
onRetry,
|
||
onBuy,
|
||
onRequestMembershipQuote,
|
||
onSubmitMembershipCheckout,
|
||
onConfirmNativePayment,
|
||
onCloseNativePayment,
|
||
}: PlatformProfileRechargeModalProps) {
|
||
const nativeQrImageUrl = useWechatNativeQrCode(
|
||
nativePayment?.codeUrl ?? null,
|
||
);
|
||
const membership = center?.membership ?? null;
|
||
const isActiveMembership =
|
||
membership?.status === 'active' && membership.plan !== 'normal';
|
||
const currentBalance = resolveProfileMudPointDisplayTotal(
|
||
center?.mudPointBalance,
|
||
);
|
||
const showDailyFreePool = shouldShowProfileDailyFreePool(
|
||
center?.mudPointBalance,
|
||
);
|
||
// 后端目录已按 rank 升序返回,这里只过滤不可购买与占位档。
|
||
const membershipPlans = useMemo(
|
||
() =>
|
||
(center?.membershipPlans ?? []).filter(
|
||
(plan) => plan.enabled && plan.plan !== 'normal',
|
||
),
|
||
[center?.membershipPlans],
|
||
);
|
||
const pointProducts = useMemo(
|
||
() => center?.pointProducts ?? [],
|
||
[center?.pointProducts],
|
||
);
|
||
const currentPlanRecord = useMemo(
|
||
() =>
|
||
membershipPlans.find((plan) => plan.plan === membership?.plan) ?? null,
|
||
[membership?.plan, membershipPlans],
|
||
);
|
||
const [activeTab, setActiveTab] =
|
||
useState<PlatformProfileRechargeTab>('subscription');
|
||
const [cycleKind, setCycleKind] =
|
||
useState<ProfileMembershipCycleKind>('monthly');
|
||
const [selectedPointProductId, setSelectedPointProductId] = useState<
|
||
string | null
|
||
>(null);
|
||
const [checkout, setCheckout] =
|
||
useState<PlatformProfileRechargeCheckoutTarget | null>(null);
|
||
|
||
// 有效会员期内不允许月付 / 年付互换,切换器锁定在当前周期。
|
||
useEffect(() => {
|
||
if (isActiveMembership && membership?.cycleKind) {
|
||
setCycleKind(membership.cycleKind);
|
||
}
|
||
}, [isActiveMembership, membership?.cycleKind]);
|
||
|
||
useEffect(() => {
|
||
setSelectedPointProductId((current) => {
|
||
if (
|
||
current &&
|
||
pointProducts.some((product) => product.productId === current)
|
||
) {
|
||
return current;
|
||
}
|
||
if (pointProducts.length === 0) {
|
||
return null;
|
||
}
|
||
// 中文注释:默认预选第 3 档(索引 2,不足时退回末档),让首屏落在价格居中的档位;
|
||
// 充点商品目前同价(¥1 = 10 泥点),这里只影响默认选中项,不改变计费。
|
||
const defaultProductIndex = Math.min(2, pointProducts.length - 1);
|
||
const fallbackProduct = pointProducts[defaultProductIndex];
|
||
return fallbackProduct ? fallbackProduct.productId : null;
|
||
});
|
||
}, [pointProducts]);
|
||
|
||
const selectedPointProduct =
|
||
pointProducts.find(
|
||
(product) => product.productId === selectedPointProductId,
|
||
) ?? null;
|
||
|
||
const openMembershipCheckout = useCallback(
|
||
(plan: ProfileMembershipPlanRecord) => {
|
||
const action = resolveMembershipPlanAction(
|
||
plan,
|
||
membership,
|
||
currentPlanRecord,
|
||
);
|
||
if (action.disabled) {
|
||
return;
|
||
}
|
||
setCheckout({
|
||
kind: 'membership',
|
||
plan: plan.plan,
|
||
cycleKind,
|
||
mode: action.mode,
|
||
});
|
||
if (action.mode === 'upgrade') {
|
||
onRequestMembershipQuote(plan.plan);
|
||
}
|
||
},
|
||
[currentPlanRecord, cycleKind, membership, onRequestMembershipQuote],
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<PlatformProfileRechargeModalShell
|
||
title="会员与泥点"
|
||
description={
|
||
currentBalance === undefined
|
||
? '可用总额读取中'
|
||
: `可用总额 ${formatMudPointCount(currentBalance)} 泥点`
|
||
}
|
||
onClose={onClose}
|
||
closeLabel="关闭会员与泥点"
|
||
size="md"
|
||
panelClassName="!max-w-[34rem] rounded-[1.4rem]"
|
||
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
|
||
>
|
||
<PlatformRechargeAsyncStatePanel
|
||
errorState={
|
||
error ? (
|
||
<PlatformRechargeStatusMessage className="mt-4 rounded-2xl font-semibold">
|
||
<div>{error}</div>
|
||
<PlatformRechargeActionButton
|
||
className="mt-3"
|
||
onClick={onRetry}
|
||
>
|
||
重新加载
|
||
</PlatformRechargeActionButton>
|
||
</PlatformRechargeStatusMessage>
|
||
) : null
|
||
}
|
||
isLoading={isLoading}
|
||
loadingState={
|
||
<PlatformRechargeSkeletonList
|
||
count={4}
|
||
containerClassName="grid gap-2.5"
|
||
itemClassName="h-[4.5rem] rounded-lg bg-white/10"
|
||
/>
|
||
}
|
||
isEmpty={membershipPlans.length === 0 && pointProducts.length === 0}
|
||
emptyState={
|
||
<PlatformRechargeEmptyState className="mt-1">
|
||
暂无可购买套餐
|
||
</PlatformRechargeEmptyState>
|
||
}
|
||
>
|
||
<div className="grid gap-4">
|
||
<PlatformRechargeTabNav
|
||
activeTab={activeTab}
|
||
onChange={setActiveTab}
|
||
/>
|
||
<PlatformMudPointPoolOverview center={center} />
|
||
|
||
<div
|
||
role="tabpanel"
|
||
id="platform-recharge-panel-subscription"
|
||
aria-labelledby="platform-recharge-tab-subscription"
|
||
hidden={activeTab !== 'subscription'}
|
||
className="grid gap-3"
|
||
>
|
||
{isActiveMembership && membership ? (
|
||
<PlatformMembershipStatusCard
|
||
membership={membership}
|
||
currentPlan={currentPlanRecord}
|
||
/>
|
||
) : null}
|
||
<PlatformMembershipCycleSwitch
|
||
cycleKind={cycleKind}
|
||
locked={isActiveMembership}
|
||
onChange={setCycleKind}
|
||
/>
|
||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||
{membershipPlans.map((plan) => {
|
||
const action = resolveMembershipPlanAction(
|
||
plan,
|
||
membership,
|
||
currentPlanRecord,
|
||
);
|
||
const planSubmitting =
|
||
submittingProductId ===
|
||
resolveMembershipProductId(plan.plan, cycleKind);
|
||
return (
|
||
<div key={plan.plan}>
|
||
<PlatformMembershipPlanCard
|
||
plan={plan}
|
||
cycleKind={cycleKind}
|
||
isCurrentPlan={plan.plan === membership?.plan}
|
||
recommended={plan.recommended}
|
||
actionLabel={action.label}
|
||
disabled={action.disabled}
|
||
submitting={planSubmitting}
|
||
onCheckout={() => openMembershipCheckout(plan)}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{membershipPlans.length > 0 ? (
|
||
<details className="platform-subpanel rounded-[1rem] px-4 py-3">
|
||
<summary className="cursor-pointer text-xs font-black text-[var(--platform-text-strong)]">
|
||
套餐对比
|
||
</summary>
|
||
<div className="mt-3">
|
||
<PlatformMembershipComparisonTable
|
||
plans={membershipPlans}
|
||
/>
|
||
</div>
|
||
</details>
|
||
) : (
|
||
<PlatformRechargeEmptyState>
|
||
暂无上架的会员套餐
|
||
</PlatformRechargeEmptyState>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
role="tabpanel"
|
||
id="platform-recharge-panel-recharge"
|
||
aria-labelledby="platform-recharge-tab-recharge"
|
||
hidden={activeTab !== 'recharge'}
|
||
className="grid gap-3"
|
||
>
|
||
{pointProducts.length > 0 ? (
|
||
<>
|
||
<PlatformPointProductGrid
|
||
products={pointProducts}
|
||
selectedProductId={selectedPointProductId}
|
||
submittingProductId={submittingProductId}
|
||
onSelect={(product) =>
|
||
setSelectedPointProductId(product.productId)
|
||
}
|
||
/>
|
||
<PlatformRechargeActionButton
|
||
className="w-full disabled:cursor-not-allowed"
|
||
disabled={!selectedPointProduct}
|
||
onClick={() => {
|
||
if (selectedPointProduct) {
|
||
setCheckout({
|
||
kind: 'points',
|
||
product: selectedPointProduct,
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
{selectedPointProduct
|
||
? `充值 ${formatRechargePrice(selectedPointProduct.priceCents)}`
|
||
: '请选择充值档位'}
|
||
</PlatformRechargeActionButton>
|
||
</>
|
||
) : (
|
||
<PlatformRechargeEmptyState>
|
||
暂无可购买套餐
|
||
</PlatformRechargeEmptyState>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</PlatformRechargeAsyncStatePanel>
|
||
</PlatformProfileRechargeModalShell>
|
||
{checkout ? (
|
||
<PlatformRechargeCheckoutDialog
|
||
target={checkout}
|
||
membershipPlans={membershipPlans}
|
||
membership={membership}
|
||
membershipQuote={membershipQuote}
|
||
isLoadingMembershipQuote={isLoadingMembershipQuote}
|
||
membershipQuoteError={membershipQuoteError}
|
||
submittingProductId={submittingProductId}
|
||
showDailyFreePool={showDailyFreePool}
|
||
onClose={() => setCheckout(null)}
|
||
onBuy={onBuy}
|
||
onRequestMembershipQuote={onRequestMembershipQuote}
|
||
onSubmitMembershipCheckout={onSubmitMembershipCheckout}
|
||
/>
|
||
) : null}
|
||
{nativePayment ? (
|
||
<PlatformProfileWechatNativePaymentModal
|
||
nativePayment={nativePayment}
|
||
nativeQrImageUrl={nativeQrImageUrl}
|
||
onClose={onCloseNativePayment}
|
||
onConfirm={onConfirmNativePayment}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|