feat(ui) : improve guide navigation, signup, and commissions
This commit is contained in:
@@ -54,6 +54,7 @@ export function CommissionIntro() {
|
||||
<div>
|
||||
<h2 id="commission-how-it-works" className="font-heading text-2xl font-semibold">ขอคำนวณแบบที่ต้องการ</h2>
|
||||
<p className="mt-2 text-muted-foreground">เลือกได้ทั้งจัดทีม เปรียบเทียบอาวุธ และเปรียบเทียบกลุ่มดาว โดยเห็นยอดก่อนชำระเงิน</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ใน Ticket</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{steps.map((step) => <Card key={step.title} size="sm">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user