feat(ui) : improve guide navigation, signup, and commissions

This commit is contained in:
2026-10-07 04:39:57 +07:00 Unverified
parent b35c1a4471
commit 22f5f96231
31 changed files with 737 additions and 400 deletions
+90 -42
View File
@@ -1,18 +1,19 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "@/components/ui/resilient-image";
import { UserRoundIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { ArtifactSelect, CharacterSelect, WeaponSelect } from "@/components/admin/ui/catalog-select";
import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { FieldGroup } from "@/components/ui/field";
import { FieldError, FieldGroup, FieldLegend, FieldSet } from "@/components/ui/field";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { commissionPrice, commissionRequestSchema, type CommissionRequest } from "@/lib/commission/request";
import { commissionPrice, type CommissionRequest } from "@/lib/commission/request";
import { commissionDraftKey, restoreCommissionDraft, serializeCommissionDraft } from "@/lib/commission/draft";
import { commissionRequestIssues } from "@/lib/commission/request-validation";
import { elementBackgroundColor } from "@/lib/catalog/element";
type Item = { key: string; name: string; aliases: string[]; imageUrl: string; rarity?: number | null;
@@ -23,23 +24,6 @@ const blankMember = (): CommissionRequest["teams"][number]["members"][number] =>
});
const blankTeam = () => ({ members: Array.from({ length: 4 }, blankMember) });
function requestError(request: CommissionRequest): string | null {
const parsed = commissionRequestSchema.safeParse(request);
if (parsed.success) return null;
const path = parsed.error.issues[0]?.path;
if (path?.[0] === "teams") {
const teamNumber = Number(path[1]) + 1;
const memberNumber = typeof path[3] === "number" ? path[3] + 1 : null;
if (memberNumber && path[4] === "characterKey") return `ทีม ${teamNumber}: เลือกตัวละครที่ ${memberNumber}`;
if (memberNumber && path[4] === "weaponKey") return `ทีม ${teamNumber}: เลือกอาวุธให้ตัวละครที่ ${memberNumber}`;
if (memberNumber && path[4] === "artifactKey") return `ทีม ${teamNumber}: เลือกอาร์ติแฟกต์ให้ตัวละครที่ ${memberNumber}`;
return `ทีม ${teamNumber}: เลือกตัวละคร 4 ตัวที่ไม่ซ้ำกัน พร้อมอาวุธและอาร์ติแฟกต์`;
}
if (path?.[0] === "weapons") return `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: เลือกตัวละครและอาวุธต่างกันอย่างน้อย 2 ชิ้น`;
if (path?.[0] === "constellations") return `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ`;
return "เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ";
}
function checkoutError(code: string | undefined, status: number): string {
if (status === 401) return "เซสชันหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง";
if (status === 403) return "ไม่สามารถส่งคำขอจากหน้านี้ได้ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง";
@@ -52,9 +36,47 @@ function checkoutError(code: string | undefined, status: number): string {
return "สร้างคำขอไม่สำเร็จ กรุณาลองอีกครั้ง";
}
export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; userId: string }) {
const router = useRouter();
const [request, setRequest] = useState<CommissionRequest>({ teams: [blankTeam()], weapons: [], constellations: [] });
const root = useRef<HTMLFieldSetElement>(null);
const restoredKey = useRef<string | null>(null);
const submitted = useRef(false);
const submitting = useRef(false);
const [hydrated, setHydrated] = useState(false);
const [draftNotice, setDraftNotice] = useState("");
const [attempted, setAttempted] = useState(false);
const storageKey = commissionDraftKey(userId);
const issues = attempted ? commissionRequestIssues(request) : [];
const invalid = (path: string) => issues.some((issue) => issue.path === path || path.startsWith(`${issue.path}.`));
const errorsAt = (path: string) => [...new Set(issues.filter((issue) => issue.path === path || issue.path.startsWith(`${path}.`)).map((issue) => issue.message))];
useEffect(() => {
if (restoredKey.current === storageKey) return;
let restored: CommissionRequest | null = null;
let notice = "แบบร่างจะบันทึกในอุปกรณ์นี้อัตโนมัติ";
try {
restored = restoreCommissionDraft(localStorage.getItem(storageKey), catalog);
if (restored) notice = "กู้คืนแบบร่างแล้ว กรุณาตรวจสอบรายการก่อนชำระเงิน";
} catch { notice = "เบราว์เซอร์ไม่อนุญาตให้บันทึกแบบร่าง กรุณาเปิดหน้านี้ไว้จนสร้างคำขอสำเร็จ"; }
const frame = requestAnimationFrame(() => {
if (restored) setRequest(restored);
restoredKey.current = storageKey;
setDraftNotice(notice);
setHydrated(true);
});
return () => cancelAnimationFrame(frame);
}, [storageKey, catalog]);
useEffect(() => {
if (!hydrated || submitted.current) return;
try { localStorage.setItem(storageKey, serializeCommissionDraft(request)); }
catch {
const frame = requestAnimationFrame(() => setDraftNotice("บันทึกแบบร่างไม่สำเร็จ กรุณาเปิดหน้านี้ไว้จนสร้างคำขอสำเร็จ"));
return () => cancelAnimationFrame(frame);
}
}, [request, hydrated, storageKey]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const patch = (next: Partial<CommissionRequest>) => setRequest((old) => ({ ...old, ...next }));
@@ -64,8 +86,19 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
};
async function submit() {
const validationError = requestError(request);
if (validationError) { setError(validationError); toast.error(validationError); return; }
if (!hydrated || submitting.current || submitted.current) return;
setAttempted(true);
const validationIssues = commissionRequestIssues(request);
if (validationIssues.length) {
setError("");
requestAnimationFrame(() => {
const field = root.current?.querySelector<HTMLElement>('[aria-invalid="true"], [data-commission-errors]');
field?.scrollIntoView({ block: "center", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
field?.focus({ preventScroll: true });
});
return;
}
submitting.current = true;
setBusy(true); setError("");
try {
const response = await fetch("/api/commission/checkouts", { method: "POST",
@@ -75,16 +108,21 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
throw new Error(checkoutError(body?.error, response.status));
}
const { checkoutId } = await response.json();
submitted.current = true;
try { localStorage.removeItem(storageKey); } catch { /* Navigation remains available when storage is blocked. */ }
router.push(`/commission/pay/${checkoutId}`);
} catch (cause) {
const message = cause instanceof Error ? cause.message : "สร้างคำขอไม่สำเร็จ";
setError(message);
toast.error(message);
}
finally { setBusy(false); }
finally { submitting.current = false; setBusy(false); }
}
return <div className="flex flex-col gap-6">
return <FieldSet ref={root} disabled={busy || !hydrated} className="flex flex-col gap-6">
<FieldLegend className="sr-only">คำขอคำนวณทีม อาวุธ และกลุ่มดาว</FieldLegend>
<p className="text-sm text-muted-foreground" role="status">{draftNotice}</p>
<p className="text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน</p>
{attempted && issues.some((issue) => !issue.path) && <Alert variant="destructive" role="alert"><AlertDescription>เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ</AlertDescription></Alert>}
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
{request.teams.map((team, teamIndex) => <Card key={teamIndex} className="w-full"><CardHeader><CardTitle>ทีม {teamIndex + 1}</CardTitle></CardHeader><CardContent className="flex flex-col gap-4">
@@ -94,21 +132,21 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
return <div key={memberIndex} className="min-w-0 rounded-lg p-1.5" style={{ backgroundColor: elementBackgroundColor(character?.element) }}>
<span className="text-xs text-muted-foreground">#{memberIndex + 1}</span>
<div className="grid grid-cols-[2fr_1fr] gap-1">
<CharacterSelect presentation="slot" items={catalog.characters} value={member.characterKey}
<CharacterSelect presentation="slot" items={catalog.characters} value={member.characterKey} invalid={invalid(`teams.${teamIndex}.members.${memberIndex}.characterKey`)}
label={`เลือกตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
placeholderIcon={<UserRoundIcon className="size-10" aria-hidden="true" />}
className="border-0 [&>img]:object-cover [&>img]:p-0" onValueChange={(characterKey) => {
const teams = structuredClone(request.teams); teams[teamIndex].members[memberIndex] = { ...blankMember(), characterKey }; patch({ teams });
}} />
<div className="grid min-w-0 grid-rows-2 gap-1">
<WeaponSelect presentation="slot" items={compatibleWeapons(member.characterKey)} value={member.weaponKey}
<WeaponSelect presentation="slot" items={compatibleWeapons(member.characterKey)} value={member.weaponKey} invalid={invalid(`teams.${teamIndex}.members.${memberIndex}.weaponKey`)}
label={`เลือกอาวุธของตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาอาวุธที่รองรับ…"
placeholderIcon={<Image src="/icon/nav/Weapon.png" alt="" width={32} height={32} className="size-8 object-contain opacity-60" />}
emptyLabel={character ? "ไม่พบอาวุธ" : "เลือกตัวละครก่อน"} onValueChange={(weaponKey) => {
const teams = structuredClone(request.teams); teams[teamIndex].members[memberIndex].weaponKey = weaponKey;
teams[teamIndex].members[memberIndex].refinement = "1"; patch({ teams });
}} />
<ArtifactSelect presentation="slot" items={catalog.artifacts} value={member.artifactKey}
<ArtifactSelect presentation="slot" items={catalog.artifacts} value={member.artifactKey} invalid={invalid(`teams.${teamIndex}.members.${memberIndex}.artifactKey`)}
label={`เลือกอาร์ติแฟกต์ของตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาชุดอาร์ติแฟกต์…"
placeholderIcon={<Image src="/icon/nav/Artifact.webp" alt="" width={32} height={32} className="size-8 object-contain opacity-60" />}
onValueChange={(artifactKey) => {
@@ -133,9 +171,11 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
}} triggerProps={{ size: "sm", className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden" }} />
</div>
<p className="mt-1 truncate text-center text-xs font-medium" title={character?.name}>{character?.name ?? "เลือกตัวละคร"}</p>
{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).length > 0 && <FieldError>{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).join(" · ")}</FieldError>}
</div>;
})}
</div>
{issues.some((issue) => issue.path === `teams.${teamIndex}`) && <FieldError data-commission-errors tabIndex={-1}>{issues.filter((issue) => issue.path === `teams.${teamIndex}`).map((issue) => issue.message).join(" · ")}</FieldError>}
<Button variant="outline" onClick={() => patch({ teams: request.teams.filter((_, index) => index !== teamIndex) })}>ลบทีม</Button>
</CardContent></Card>)}
<Button variant="outline" onClick={() => patch({ teams: [...request.teams, blankTeam()] })}>เพิ่มทีม</Button>
@@ -149,7 +189,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
<div className="min-w-0 flex-1">
<WeaponSelect showSelectedImage showClear={false}
items={compatibleWeapons(set.characterKey).filter((item) => !set.weaponKeys.includes(item.key) || item.key === weaponKey)}
value={weaponKey} label={`เลือกอาวุธเปรียบเทียบชิ้นที่ ${weaponIndex + 1}`}
value={weaponKey} invalid={invalid(`weapons.${index}.weaponKeys.${weaponIndex}`)} label={`เลือกอาวุธเปรียบเทียบชิ้นที่ ${weaponIndex + 1}`}
placeholder={`อาวุธชิ้นที่ ${weaponIndex + 1}`} emptyLabel={set.characterKey ? "ไม่พบอาวุธ" : "เลือกตัวละครก่อน"}
onValueChange={(next) => {
const weapons = structuredClone(request.weapons);
@@ -179,7 +219,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
})}
</div>
<div className="relative w-20 sm:w-24">
<CharacterSelect presentation="slot" items={catalog.characters} value={set.characterKey}
<CharacterSelect presentation="slot" items={catalog.characters} value={set.characterKey} invalid={invalid(`weapons.${index}.characterKey`)}
label={`เลือกตัวละครเปรียบเทียบอาวุธชุดที่ ${index + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
placeholderIcon={<UserRoundIcon className="size-9" aria-hidden="true" />}
className="border-0 [&>img]:object-cover [&>img]:p-0" onValueChange={(characterKey) => {
@@ -194,6 +234,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
</div>
</div>
{errorsAt(`weapons.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`weapons.${index}`).join(" · ")}</FieldError>}
<Button variant="outline" disabled={set.weaponKeys.length >= 100} onClick={() => {
const weapons = structuredClone(request.weapons); weapons[index].weaponKeys.push(""); patch({ weapons });
}}>เพิ่มอาวุธ</Button>
@@ -205,14 +246,14 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
{request.constellations.map((set, index) => <FieldGroup key={index} className="flex w-full flex-col gap-3 rounded-lg border p-4">
<div className="grid grid-cols-[minmax(0,1fr)_80px] items-start gap-3 sm:grid-cols-[minmax(0,1fr)_96px]">
<div className="flex flex-wrap gap-4">{Array.from({ length: 7 }, (_, level) => <label key={level} className="flex items-center gap-2 text-sm">
<Checkbox checked={set.levels.includes(level)} onCheckedChange={(checked) => {
<Checkbox aria-invalid={invalid(`constellations.${index}.levels`)} checked={set.levels.includes(level)} onCheckedChange={(checked) => {
const constellations = structuredClone(request.constellations);
constellations[index].levels = checked ? [...set.levels, level].sort() : set.levels.filter((item) => item !== level);
patch({ constellations });
}} /> C{level}
</label>)}</div>
<div className="w-20 sm:w-24">
<CharacterSelect presentation="slot" items={catalog.characters} value={set.characterKey}
<CharacterSelect presentation="slot" items={catalog.characters} value={set.characterKey} invalid={invalid(`constellations.${index}.characterKey`)}
label={`เลือกตัวละครเปรียบเทียบกลุ่มดาวชุดที่ ${index + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
placeholderIcon={<UserRoundIcon className="size-9" aria-hidden="true" />}
className="border-0 [&>img]:object-cover [&>img]:p-0" onValueChange={(characterKey) => {
@@ -222,15 +263,22 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
</div>
</div>
{errorsAt(`constellations.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`constellations.${index}`).join(" · ")}</FieldError>}
<Button variant="outline" onClick={() => patch({ constellations: request.constellations.filter((_, i) => i !== index) })}>ลบชุดนี้</Button>
</FieldGroup>)}
<Button variant="outline" onClick={() => patch({ constellations: [...request.constellations, { characterKey: "", levels: [] }] })}>เพิ่มชุดกลุ่มดาว</Button>
</CardContent></Card>
<Card><CardContent className="flex items-center justify-between gap-4"><div><strong>รวม ฿{commissionPrice(request)}</strong>
{request.teams.length === 0 && request.weapons.length + request.constellations.length > 0 ?
<p className="text-sm text-muted-foreground">รวมค่าบริการพื้นฐาน ฿100</p> : null}</div>
<Button disabled={busy || commissionPrice(request) === 0} onClick={submit}>{busy ? "กำลังสร้างคำขอ..." : "ยืนยันและชำระเงิน"}</Button>
</CardContent></Card>
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
</div>;
<Card className="sticky bottom-2 z-20 shadow-lg">
<CardHeader className="hidden sm:grid"><CardTitle>สรุปคำขอ</CardTitle><CardDescription>
{request.teams.length} ทีม · {request.weapons.reduce((sum, set) => sum + set.weaponKeys.filter(Boolean).length, 0)} อาวุธ · {request.constellations.reduce((sum, set) => sum + set.levels.length, 0)} ระดับกลุ่มดาว
</CardDescription></CardHeader>
<CardFooter className="flex flex-wrap items-center justify-between gap-3">
{error && <Alert variant="destructive" role="alert" className="w-full"><AlertDescription>{error}</AlertDescription></Alert>}
<div><strong className="text-lg">รวม ฿{commissionPrice(request)}</strong>
{request.teams.length === 0 && request.weapons.length + request.constellations.length > 0 && <p className="text-sm text-muted-foreground">รวมค่าบริการพื้นฐาน ฿100</p>}
</div>
<Button disabled={!hydrated || busy || commissionPrice(request) === 0} onClick={() => void submit()}>{busy ? "กำลังสร้างคำขอ…" : "ยืนยันและชำระเงิน"}</Button>
</CardFooter>
</Card>
</FieldSet>;
}