280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|