feat(player): 优化桌面投注布局与账号功能
This commit is contained in:
279
src/features/player/player-account-dialog.tsx
Normal file
279
src/features/player/player-account-dialog.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, KeyRound, Loader2, LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { putPlayerPassword } from "@/api/player-auth";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { validatePlayerLoginPassword } from "@/lib/player-input-validation";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
type AccountView = "profile" | "password";
|
||||
|
||||
function ProfileRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7.5rem_minmax(0,1fr)] items-start gap-3 py-2.5 text-sm">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-right font-medium text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function accountInitial(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? "P" : trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
export function PlayerAccountDialog() {
|
||||
const { t } = useTranslation("player");
|
||||
const router = useRouter();
|
||||
const profile = usePlayerSessionStore((state) => state.profile);
|
||||
const clearBearerToken = usePlayerSessionStore((state) => state.clearBearerToken);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [view, setView] = useState<AccountView>("profile");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const displayName =
|
||||
profile?.nickname?.trim() ||
|
||||
profile?.username?.trim() ||
|
||||
(profile?.id != null ? t("player.fallback", { id: profile.id }) : t("account.loading"));
|
||||
const isNative = profile?.auth_source === "lottery_native";
|
||||
|
||||
function resetPasswordForm() {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setView("profile");
|
||||
resetPasswordForm();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
handleOpenChange(false);
|
||||
clearBearerToken();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (currentPassword === "" || newPassword === "" || confirmPassword === "") {
|
||||
toast.error(t("account.password.required"));
|
||||
return;
|
||||
}
|
||||
if (validatePlayerLoginPassword(newPassword) === "too_short") {
|
||||
toast.error(t("account.password.tooShort"));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t("account.password.mismatch"));
|
||||
return;
|
||||
}
|
||||
if (newPassword === currentPassword) {
|
||||
toast.error(t("account.password.mustDiffer"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await putPlayerPassword({
|
||||
current_password: currentPassword,
|
||||
password: newPassword,
|
||||
password_confirmation: confirmPassword,
|
||||
});
|
||||
handleOpenChange(false);
|
||||
clearBearerToken();
|
||||
router.replace("/login?password=changed");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("account.password.failed"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={profile === null}
|
||||
aria-label={t("account.open")}
|
||||
onClick={() => setOpen(true)}
|
||||
className="max-w-[8.5rem] justify-start rounded-full bg-muted/40 px-1.5 sm:max-w-[11rem]"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{displayName}</span>
|
||||
<ChevronDown data-icon="inline-end" className="hidden sm:block" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{view === "profile" ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("account.title")}</DialogTitle>
|
||||
<DialogDescription>{t("account.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate">{displayName}</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{profile?.username || profile?.site_player_id || "—"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant="secondary">
|
||||
{isNative ? t("account.auth.native") : t("account.auth.sso")}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{profile?.funding_mode === "credit"
|
||||
? t("account.funding.credit")
|
||||
: t("account.funding.wallet")}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl>
|
||||
<ProfileRow label={t("account.fields.username")} value={profile?.username || "—"} />
|
||||
<Separator />
|
||||
<ProfileRow label={t("account.fields.site")} value={profile?.site_code || "—"} />
|
||||
<Separator />
|
||||
<ProfileRow
|
||||
label={t("account.fields.playerId")}
|
||||
value={profile?.site_player_id || "—"}
|
||||
/>
|
||||
<Separator />
|
||||
<ProfileRow
|
||||
label={t("account.fields.currency")}
|
||||
value={profile?.default_currency || "—"}
|
||||
/>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isNative ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t("account.ssoManaged")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isNative ? (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="destructive" onClick={handleLogout}>
|
||||
<LogOut data-icon="inline-start" />
|
||||
{t("account.logout")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setView("password")}>
|
||||
<KeyRound data-icon="inline-start" />
|
||||
{t("account.password.action")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("account.password.title")}</DialogTitle>
|
||||
<DialogDescription>{t("account.password.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="player-current-password">
|
||||
{t("account.password.current")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="player-current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="player-new-password">
|
||||
{t("account.password.new")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="player-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
<FieldDescription>{t("account.password.hint")}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="player-confirm-password">
|
||||
{t("account.password.confirm")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="player-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : null}
|
||||
{saving ? t("account.password.saving") : t("account.password.submit")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={saving} onClick={() => setView("profile")}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user