- Added styles for player-side toast notifications to improve user feedback. - Adjusted padding and spacing in various components for a more cohesive layout. - Updated card and dialog components to streamline visual hierarchy and enhance readability. - Refactored player panel and navigation elements for better alignment and user experience.
455 lines
12 KiB
TypeScript
455 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { isAxiosError } from "axios";
|
||
import { Loader2 } from "lucide-react";
|
||
import { useMemo, 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";
|
||
|
||
/** 处理中 / 待对账:刷新数据后提示用文案即可 */
|
||
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();
|
||
return true;
|
||
}
|
||
if (isAxiosError(e) && e.response?.status === 409) {
|
||
toast.message(t("wallet.pendingShort"));
|
||
await onRefresh();
|
||
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,
|
||
onSuccess,
|
||
idPrefix = "",
|
||
onCancel,
|
||
variant = "dialog",
|
||
}: PanelBase & {
|
||
lotteryMinor: 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 tid = `${idPrefix}in-amount`;
|
||
|
||
const parsedMinor = useMemo(
|
||
() => parseDecimalInputToMinor(amountText, currency),
|
||
[amountText, currency],
|
||
);
|
||
const previewAfter =
|
||
parsedMinor != null ? lotteryMinor + parsedMinor : lotteryMinor;
|
||
|
||
const submit = async () => {
|
||
setLocalError(null);
|
||
if (parsedMinor == null || parsedMinor < 1) {
|
||
setLocalError(t("wallet.invalidAmount"));
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
await postWalletTransferIn({
|
||
amount: parsedMinor,
|
||
currency,
|
||
idempotent_key: randomIdempotentKey(),
|
||
});
|
||
toast.success(t("wallet.successIn"));
|
||
setAmountText("");
|
||
await onSuccess();
|
||
} catch (e) {
|
||
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||
setLocalError(formatWalletClientError(e, 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 text-foreground">{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 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 () => {
|
||
setLocalError(null);
|
||
if (parsedMinor == null || parsedMinor < 1) {
|
||
setLocalError(t("wallet.invalidAmount"));
|
||
return;
|
||
}
|
||
if (parsedMinor > availableMinor) {
|
||
setLocalError(t("wallet.outExceeds"));
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
await postWalletTransferOut({
|
||
amount: parsedMinor,
|
||
currency,
|
||
idempotent_key: randomIdempotentKey(),
|
||
});
|
||
toast.success(t("wallet.successOut"));
|
||
setAmountText("");
|
||
await onSuccess();
|
||
} catch (e) {
|
||
if (await handleTransferMaybePending(e, onSuccess, t)) {
|
||
setLocalError(formatWalletClientError(e, 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,
|
||
onSuccess,
|
||
}: PanelBase & { lotteryMinor: number }) {
|
||
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}
|
||
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>
|
||
);
|
||
}
|