Files
Genarrative/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx
T
k88936 125927e0e8 Ai game creator app home sidebar (#86)
![shotmd-1784186268.jpg](/attachments/c828773c-4f1a-4654-b76d-c944b82ec023)
![shotmd-1784186877.jpg](/attachments/f1782ba3-245a-40c1-8caa-cbd7e4cce74b)
![shotmd-1784186948-compressed.webp](/attachments/f3d02ac3-db7a-439b-9a84-7aceefad9048)
TODO: 补充说明书链接

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/86
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-20 15:03:23 +08:00

536 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
</>
);
}