125927e0e8
   TODO: 补充说明书链接 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/86 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
536 lines
15 KiB
TypeScript
536 lines
15 KiB
TypeScript
import './index.css';
|
||
|
||
import QRCode from 'qrcode';
|
||
import type { ReactNode } from 'react';
|
||
import { useCallback, useEffect, useId, useState } from 'react';
|
||
|
||
import type {
|
||
ProfileRechargeCenterResponse,
|
||
ProfileRechargeProduct,
|
||
} from '../../contracts/runtime';
|
||
import { formatMudPointCount, formatRechargePrice } from '../../utils/format';
|
||
|
||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||
|
||
export type PlatformProfileRechargeNativePaymentState = {
|
||
orderId: string;
|
||
productTitle: string;
|
||
amountCents: number;
|
||
codeUrl: string;
|
||
expiresAt: string;
|
||
isConfirming: boolean;
|
||
confirmMessage?: string;
|
||
};
|
||
|
||
export type PlatformProfileRechargeModalProps = {
|
||
center: ProfileRechargeCenterResponse | null;
|
||
isLoading: boolean;
|
||
error: string | null;
|
||
submittingProductId: string | null;
|
||
nativePayment: PlatformProfileRechargeNativePaymentState | null;
|
||
onClose: () => void;
|
||
onRetry: () => void;
|
||
onBuy: (product: ProfileRechargeProduct) => 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,
|
||
onClick,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
disabled?: boolean;
|
||
onClick?: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
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 PlatformRechargePillBadge({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<span
|
||
className={joinClassNames(
|
||
'platform-profile-recharge-modal__badge-warning inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-black',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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),
|
||
};
|
||
}
|
||
|
||
function RechargeProductCard({
|
||
product,
|
||
submittingProductId,
|
||
onBuy,
|
||
}: {
|
||
product: ProfileRechargeProduct;
|
||
submittingProductId: string | null;
|
||
onBuy: (product: ProfileRechargeProduct) => void;
|
||
}) {
|
||
const submitting = submittingProductId === product.productId;
|
||
const badgeLabel = product.badgeLabel;
|
||
const bonusLabel =
|
||
product.bonusPoints > 0
|
||
? `+${formatMudPointCount(product.bonusPoints)}泥点`
|
||
: null;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onBuy(product)}
|
||
disabled={Boolean(submittingProductId)}
|
||
aria-label={`${formatMudPointCount(product.pointsAmount)}泥点${bonusLabel ? ` ${bonusLabel}` : ''} ${formatRechargePrice(product.priceCents)} 购买`}
|
||
className="platform-recharge-product-row platform-subpanel platform-interactive-card platform-profile-recharge-modal__subpanel platform-profile-recharge-modal__interactive-card relative grid min-h-[4.5rem] grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 rounded-[1rem] px-3.5 py-3 text-left transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] disabled:cursor-not-allowed disabled:opacity-55"
|
||
>
|
||
<div className="min-w-0">
|
||
{badgeLabel ? (
|
||
<PlatformRechargePillBadge className="mb-1.5 max-w-[7rem] truncate px-2 py-0.5">
|
||
{badgeLabel}
|
||
</PlatformRechargePillBadge>
|
||
) : null}
|
||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||
<span className="text-sm font-black text-[var(--platform-text-strong)]">
|
||
{formatMudPointCount(product.pointsAmount)}泥点
|
||
</span>
|
||
{bonusLabel ? (
|
||
<span className="text-xs font-bold text-[var(--platform-accent-strong)]">
|
||
{bonusLabel}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<span className="text-sm font-black tabular-nums text-[var(--platform-text-strong)]">
|
||
{formatRechargePrice(product.priceCents)}
|
||
</span>
|
||
<span className="platform-profile-recharge-modal__primary-button rounded-full px-3 py-1.5 text-xs font-black">
|
||
{submitting ? '处理中' : '购买'}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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,
|
||
onClose,
|
||
onRetry,
|
||
onBuy,
|
||
onConfirmNativePayment,
|
||
onCloseNativePayment,
|
||
}: PlatformProfileRechargeModalProps) {
|
||
const nativeQrImageUrl = useWechatNativeQrCode(
|
||
nativePayment?.codeUrl ?? null,
|
||
);
|
||
const products = center?.pointProducts ?? [];
|
||
const currentBalance =
|
||
center?.mudPointBalance?.totalPoints ?? center?.walletBalance;
|
||
|
||
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={products.length === 0}
|
||
emptyState={
|
||
<PlatformRechargeEmptyState className="mt-1">
|
||
暂无可购买套餐
|
||
</PlatformRechargeEmptyState>
|
||
}
|
||
>
|
||
<div className="grid gap-2.5">
|
||
{products.map((product) => (
|
||
<RechargeProductCard
|
||
key={product.productId}
|
||
product={product}
|
||
submittingProductId={submittingProductId}
|
||
onBuy={onBuy}
|
||
/>
|
||
))}
|
||
</div>
|
||
</PlatformRechargeAsyncStatePanel>
|
||
</PlatformProfileRechargeModalShell>
|
||
{nativePayment ? (
|
||
<PlatformProfileWechatNativePaymentModal
|
||
nativePayment={nativePayment}
|
||
nativeQrImageUrl={nativeQrImageUrl}
|
||
onClose={onCloseNativePayment}
|
||
onConfirm={onConfirmNativePayment}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|