feat(hall): 支持全保金额整除校验与规则提示,优化桌面端转账按钮布局
Some checks failed
lotteryfront CI / build (push) Has been cancelled
Some checks failed
lotteryfront CI / build (push) Has been cancelled
This commit is contained in:
260
src/features/player/player-entry-desktop.tsx
Normal file
260
src/features/player/player-entry-desktop.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type DesktopEntryStepStatus = "pending" | "in-progress" | "done" | "error";
|
||||
|
||||
type DesktopEntryStep = {
|
||||
id: "token" | "account" | "hall";
|
||||
status: DesktopEntryStepStatus;
|
||||
};
|
||||
|
||||
type DesktopFailureRow = {
|
||||
code?: string;
|
||||
detailKey?: string;
|
||||
fallbackMessage?: string;
|
||||
};
|
||||
|
||||
type PlayerEntryDesktopProps = {
|
||||
phase: "loading" | "success" | "failed";
|
||||
waitingForToken: boolean;
|
||||
progress: number;
|
||||
steps: DesktopEntryStep[];
|
||||
failureDetails: DesktopFailureRow[];
|
||||
t: TFunction<"entry">;
|
||||
tc: TFunction<"common">;
|
||||
mainSiteUrl: string;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
/** PC Token 入场校验,视觉与账号密码登录保持一致。 */
|
||||
export function PlayerEntryDesktop({
|
||||
phase,
|
||||
waitingForToken,
|
||||
progress,
|
||||
steps,
|
||||
failureDetails,
|
||||
t,
|
||||
tc,
|
||||
mainSiteUrl,
|
||||
onRetry,
|
||||
}: PlayerEntryDesktopProps) {
|
||||
const isLoading = phase === "loading" || waitingForToken;
|
||||
|
||||
return (
|
||||
<div className="relative hidden min-h-0 flex-1 overflow-y-auto lg:flex lg:items-center lg:justify-center lg:px-4 lg:py-4 xl:px-8 xl:py-10">
|
||||
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
||||
<Image
|
||||
src="/entry/login-pc-bg.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover object-center"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-[min(560px,calc(100dvh-2rem))] w-full max-w-[960px] overflow-hidden rounded-[1.5rem] bg-white shadow-[0_24px_80px_rgba(15,23,42,0.12)] xl:min-h-[min(640px,88vh)] xl:max-w-[1080px] xl:rounded-[2rem]">
|
||||
<div className="relative w-[min(48%,400px)] shrink-0 self-stretch overflow-hidden bg-[#f8fafc] xl:w-[min(50%,460px)]">
|
||||
<Image
|
||||
src="/entry/login-pc-hero.png"
|
||||
alt={t("header.backgroundAlt")}
|
||||
fill
|
||||
sizes="(max-width: 1280px) 400px, 460px"
|
||||
className="object-cover object-center"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col px-6 py-5 xl:px-10 xl:py-10">
|
||||
<div className="flex justify-end">
|
||||
<LanguageSwitcher variant="pill" menuAlign="end" showFlag={false} useFullLabel />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-[25rem] flex-1 flex-col justify-center">
|
||||
{isLoading ? (
|
||||
<EntryProgress progress={progress} steps={steps} t={t} />
|
||||
) : null}
|
||||
{phase === "failed" ? (
|
||||
<EntryFailure
|
||||
details={failureDetails}
|
||||
mainSiteUrl={mainSiteUrl}
|
||||
onRetry={onRetry}
|
||||
t={t}
|
||||
tc={tc}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 text-xs font-medium text-slate-500">
|
||||
<ShieldCheck className="size-4 text-red-500" aria-hidden />
|
||||
<span>{t("footer.secure")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryProgress({
|
||||
progress,
|
||||
steps,
|
||||
t,
|
||||
}: Pick<PlayerEntryDesktopProps, "progress" | "steps" | "t">) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-red-600 text-white shadow-[0_6px_16px_rgba(220,38,38,0.2)]">
|
||||
<Globe className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-semibold text-slate-900">{t("loading.title")}</h1>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{t("loading.progress")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-2 flex justify-between text-xs text-slate-500">
|
||||
<span>{t("loading.progress")}</span>
|
||||
<span className="font-semibold text-red-600">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-600 transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="flex items-start gap-3">
|
||||
<StepIcon status={step.status} />
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-slate-800">{t(`steps.${step.id}.title`)}</span>
|
||||
<StepBadge status={step.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
{t(`steps.${step.id}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryFailure({
|
||||
details,
|
||||
mainSiteUrl,
|
||||
onRetry,
|
||||
t,
|
||||
tc,
|
||||
}: Pick<PlayerEntryDesktopProps, "mainSiteUrl" | "onRetry" | "t" | "tc"> & {
|
||||
details: DesktopFailureRow[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-7 text-center">
|
||||
<div className="mx-auto mb-4 flex size-14 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<AlertTriangle className="size-7" aria-hidden />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-red-600">{t("failure.title")}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{t("failure.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{details.length > 0 ? (
|
||||
<div className="mb-6 overflow-hidden rounded-xl border border-red-100 bg-red-50/60">
|
||||
<div className="border-b border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-800">
|
||||
{t("failure.detailsTitle")}
|
||||
</div>
|
||||
<div className="divide-y divide-red-100">
|
||||
{details.map((detail, index) => (
|
||||
<div key={`${detail.code}-${index}`} className="grid grid-cols-[2rem_6.5rem_minmax(0,1fr)] gap-2 px-4 py-3 text-xs">
|
||||
<span className="text-slate-400">{index + 1}</span>
|
||||
<span className="font-medium text-slate-700">{detail.code ?? tc("errors.general")}</span>
|
||||
<span className="leading-5 text-slate-600">
|
||||
{detail.detailKey
|
||||
? t(detail.detailKey)
|
||||
: (detail.fallbackMessage ?? t("errors.unknown"))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onRetry} className="flex-1 gap-2 bg-red-600 text-white hover:bg-red-700" type="button">
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
{t("failure.reenter")}
|
||||
</Button>
|
||||
{mainSiteUrl ? (
|
||||
<a
|
||||
href={mainSiteUrl}
|
||||
target="_top"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline" }), "flex-1")}
|
||||
>
|
||||
{t("failure.backToMainSite")}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({ status }: { status: DesktopEntryStepStatus }) {
|
||||
const className = cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
|
||||
status === "done" && "border-emerald-500 bg-emerald-500 text-white",
|
||||
status === "in-progress" && "border-blue-600 bg-blue-600 text-white",
|
||||
status === "pending" && "border-slate-200 bg-slate-50 text-slate-400",
|
||||
status === "error" && "border-red-500 bg-red-500 text-white",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{status === "done" ? <CheckCircle2 className="size-4" aria-hidden /> : null}
|
||||
{status === "in-progress" ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
|
||||
{status === "pending" ? <div className="size-2 rounded-full bg-current" aria-hidden /> : null}
|
||||
{status === "error" ? <AlertCircle className="size-4" aria-hidden /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepBadge({ status }: { status: DesktopEntryStepStatus }) {
|
||||
const { t } = useTranslation("common");
|
||||
const className = cn(
|
||||
"shrink-0 rounded px-2 py-0.5 text-xs font-medium",
|
||||
status === "done" && "bg-emerald-50 text-emerald-700",
|
||||
status === "in-progress" && "bg-blue-50 text-blue-700",
|
||||
status === "pending" && "bg-slate-100 text-slate-500",
|
||||
status === "error" && "bg-red-50 text-red-700",
|
||||
);
|
||||
const label = {
|
||||
done: t("status.done"),
|
||||
"in-progress": t("status.inProgress"),
|
||||
pending: t("status.pending"),
|
||||
error: t("status.failed"),
|
||||
}[status];
|
||||
|
||||
return <span className={className}>{label}</span>;
|
||||
}
|
||||
Reference in New Issue
Block a user