Files
lotteryFront/src/features/wallet/wallet-transfer-forms.tsx
kang 58afa8e844 feat: 增强开奖 API 的币种支持并优化钱包处理逻辑
更新 getDrawCurrent、getDrawResults 与 getDrawResultByNo 方法,新增币种参数支持,以适配玩家币种偏好。
优化 HallBettingGrid 及相关组件:支持币种切换时自动刷新钱包数据。
重构钱包处理逻辑,简化余额更新流程并提升用户体验。
新增会话过期相关多语言提示文案,并优化现有翻译内容,提升多语言环境下的提示清晰度。
2026-05-27 16:52:12 +08:00

487 lines
13 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.
"use client";
import { isAxiosError } from "axios";
import { Loader2 } from "lucide-react";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
formatMinorAsCurrency,
getCurrencyDecimalPlaces,
parseDecimalInputToMinor,
} from "@/lib/money";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { randomIdempotentKey } from "@/lib/utils";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { LotteryApiBizError } from "@/types/api/errors";
/** 处理中 / 待对账:刷新数据后提示用文案即可 */
function emitWalletRefresh() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("lottery-wallet-refresh"));
}
}
async function handleTransferMaybePending(
e: unknown,
onRefresh: () => Promise<void>,
t: (key: string) => string,
): Promise<boolean> {
if (e instanceof LotteryApiBizError && e.code === 1002) {
toast.message(e.message || t("wallet.pendingToast"));
await onRefresh();
emitWalletRefresh();
return true;
}
if (isAxiosError(e) && e.response?.status === 409) {
toast.message(t("wallet.pendingShort"));
await onRefresh();
emitWalletRefresh();
return true;
}
return false;
}
type PanelBase = {
currency: string;
idPrefix?: string;
/** 提交成功后刷新余额等 */
onSuccess: () => Promise<void>;
};
type PanelVariant = "dialog" | "page";
function TransferInfoBlock({ children }: { children: ReactNode }) {
return (
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2.5 text-sm leading-relaxed">
{children}
</div>
);
}
function TransferPreview({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg bg-muted/50 px-3 py-2 text-sm text-muted-foreground">{children}</p>
);
}
function TransferError({ message }: { message: string }) {
return (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{message}
</p>
);
}
function TransferDialogFooter({
submitting,
confirmLabel,
onConfirm,
onCancel,
}: {
submitting: boolean;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation("player");
return (
<div className="mt-5 grid gap-2">
<Button
type="button"
size="lg"
className="h-11 w-full bg-[#07459f] text-base font-semibold text-white hover:bg-[#063b88]"
disabled={submitting}
onClick={onConfirm}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
confirmLabel
)}
</Button>
<Button
type="button"
variant="outline"
size="lg"
className="h-10 w-full"
disabled={submitting}
onClick={onCancel}
>
{t("actions.cancel")}
</Button>
</div>
);
}
/** 弹窗内:取消关闭;独立页:仅展示提交(返回用顶栏或上方链接) */
export function TransferInPanel({
currency,
lotteryMinor,
mainMinor = null,
onSuccess,
idPrefix = "",
onCancel,
variant = "dialog",
}: PanelBase & {
lotteryMinor: number;
mainMinor?: number | null;
onCancel: () => void;
variant?: PanelVariant;
}) {
const { t } = useTranslation("player");
useCurrencyCatalog();
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const idempotentKeyRef = useRef<string | null>(null);
const tid = `${idPrefix}in-amount`;
const parsedMinor = useMemo(
() => parseDecimalInputToMinor(amountText, currency),
[amountText, currency],
);
const previewAfter =
parsedMinor != null ? lotteryMinor + parsedMinor : lotteryMinor;
const submit = async () => {
if (submitting) {
return;
}
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError(t("wallet.invalidAmount"));
return;
}
if (idempotentKeyRef.current === null) {
idempotentKeyRef.current = randomIdempotentKey();
}
setSubmitting(true);
try {
await postWalletTransferIn({
amount: parsedMinor,
currency,
idempotent_key: idempotentKeyRef.current,
});
idempotentKeyRef.current = null;
toast.success(t("wallet.successIn"));
setAmountText("");
await onSuccess();
emitWalletRefresh();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess, t)) {
return;
}
setLocalError(formatWalletClientError(e, t));
} finally {
setSubmitting(false);
}
};
const footer =
variant === "page" ? (
<Button
type="button"
size="lg"
className="mt-5 h-11 w-full bg-[#07459f] text-base font-semibold text-white hover:bg-[#063b88]"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
t("wallet.confirmIn")
)}
</Button>
) : (
<TransferDialogFooter
submitting={submitting}
confirmLabel={t("wallet.confirmIn")}
onConfirm={() => void submit()}
onCancel={onCancel}
/>
);
return (
<>
<div className="grid gap-3">
<TransferInfoBlock>
<p className="text-muted-foreground">
{t("wallet.mainBalance")}{" "}
<span className="font-medium tabular-nums text-foreground">
{mainMinor != null
? formatMinorAsCurrency(mainMinor, currency)
: t("wallet.mainPending")}
</span>
</p>
<p className="mt-2 text-muted-foreground">
{t("wallet.lotteryBalance")}{" "}
<span className="font-semibold tabular-nums text-foreground">
{formatMinorAsCurrency(lotteryMinor, currency)}
</span>
</p>
</TransferInfoBlock>
<div className="grid gap-2">
<Label htmlFor={tid}>{t("wallet.inAmount")}</Label>
<Input
id={tid}
inputMode="decimal"
placeholder={t("wallet.exampleIn")}
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
className="h-11 text-base tabular-nums"
/>
<TransferPreview>
{t("wallet.afterInPreview", {
amount: formatMinorAsCurrency(previewAfter, currency),
})}
</TransferPreview>
</div>
{localError ? <TransferError message={localError} /> : null}
</div>
{footer}
</>
);
}
export function TransferOutPanel({
currency,
availableMinor,
onSuccess,
idPrefix = "",
onCancel,
variant = "dialog",
}: PanelBase & {
availableMinor: number;
onCancel: () => void;
variant?: PanelVariant;
}) {
const { t } = useTranslation("player");
useCurrencyCatalog();
const [amountText, setAmountText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const idempotentKeyRef = useRef<string | null>(null);
const tid = `${idPrefix}out-amount`;
const parsedMinor = useMemo(
() => parseDecimalInputToMinor(amountText, currency),
[amountText, currency],
);
const previewAfter =
parsedMinor != null
? Math.max(0, availableMinor - parsedMinor)
: availableMinor;
const fillAll = () => {
const decimals = getCurrencyDecimalPlaces(currency);
const major = availableMinor / 10 ** decimals;
setAmountText(major.toFixed(decimals));
};
const submit = async () => {
if (submitting) {
return;
}
setLocalError(null);
if (parsedMinor == null || parsedMinor < 1) {
setLocalError(t("wallet.invalidAmount"));
return;
}
if (parsedMinor > availableMinor) {
setLocalError(t("wallet.outExceeds"));
return;
}
if (idempotentKeyRef.current === null) {
idempotentKeyRef.current = randomIdempotentKey();
}
setSubmitting(true);
try {
await postWalletTransferOut({
amount: parsedMinor,
currency,
idempotent_key: idempotentKeyRef.current,
});
idempotentKeyRef.current = null;
toast.success(t("wallet.successOut"));
setAmountText("");
await onSuccess();
emitWalletRefresh();
} catch (e) {
if (await handleTransferMaybePending(e, onSuccess, t)) {
return;
}
setLocalError(formatWalletClientError(e, t));
} finally {
setSubmitting(false);
}
};
const footer =
variant === "page" ? (
<Button
type="button"
size="lg"
className="mt-5 h-11 w-full bg-[#07459f] text-base font-semibold text-white hover:bg-[#063b88]"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("actions.processing")}
</>
) : (
t("wallet.confirmOut")
)}
</Button>
) : (
<TransferDialogFooter
submitting={submitting}
confirmLabel={t("wallet.confirmOut")}
onConfirm={() => void submit()}
onCancel={onCancel}
/>
);
return (
<>
<div className="grid gap-3">
<TransferInfoBlock>
<p className="text-muted-foreground">
{t("wallet.lotteryAvailable")}{" "}
<span className="font-semibold tabular-nums text-foreground">
{formatMinorAsCurrency(availableMinor, currency)}
</span>
</p>
</TransferInfoBlock>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={tid}>{t("wallet.outAmount")}</Label>
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium"
onClick={fillAll}
disabled={submitting || availableMinor < 1}
>
{t("wallet.allOut", {
amount: formatMinorAsCurrency(availableMinor, currency),
})}
</Button>
</div>
<Input
id={tid}
inputMode="decimal"
placeholder={t("wallet.exampleOut")}
value={amountText}
onChange={(ev) => setAmountText(ev.target.value)}
disabled={submitting}
autoComplete="off"
className="h-11 text-base tabular-nums"
/>
<TransferPreview>
{t("wallet.afterOutPreview", {
amount: formatMinorAsCurrency(previewAfter, currency),
})}
</TransferPreview>
</div>
{localError ? <TransferError message={localError} /> : null}
</div>
{footer}
</>
);
}
/** 独立路由页:转入(仅组合 Card + Panel */
export function TransferInPage({
currency,
lotteryMinor,
mainMinor = null,
onSuccess,
}: PanelBase & { lotteryMinor: number; mainMinor?: number | null }) {
const { t } = useTranslation("player");
return (
<PlayerPanel
title={t("wallet.transferInTitle")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<Card>
<CardHeader>
<CardTitle>{t("wallet.transferInTitle")}</CardTitle>
<CardDescription>{t("wallet.transferInDescription")}</CardDescription>
</CardHeader>
<CardContent>
<TransferInPanel
variant="page"
currency={currency}
lotteryMinor={lotteryMinor}
mainMinor={mainMinor}
idPrefix="page-"
onSuccess={onSuccess}
onCancel={() => {}}
/>
</CardContent>
</Card>
</PlayerPanel>
);
}
/** 独立路由页:转出 */
export function TransferOutPage({
currency,
availableMinor,
onSuccess,
}: PanelBase & { availableMinor: number }) {
const { t } = useTranslation("player");
return (
<PlayerPanel
title={t("wallet.transferOutTitle")}
backHref="/wallet"
backLabel={t("wallet.title")}
>
<Card>
<CardHeader>
<CardTitle>{t("wallet.transferOutTitle")}</CardTitle>
<CardDescription>{t("wallet.transferOutDescription")}</CardDescription>
</CardHeader>
<CardContent>
<TransferOutPanel
variant="page"
currency={currency}
availableMinor={availableMinor}
idPrefix="page-"
onSuccess={onSuccess}
onCancel={() => {}}
/>
</CardContent>
</Card>
</PlayerPanel>
);
}