Files
buzz-sheet/components/commission/request-form.tsx
T
gunshiz 6d0feb11ca
CI / Verify (push) Successful in 1m45s
CI / Build immutable images and deploy (push) Successful in 2m26s
feat : update
2026-10-07 19:01:45 +07:00

286 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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 { 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, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { FieldError, FieldGroup, FieldLegend, FieldSet } from "@/components/ui/field";
import { Alert, AlertDescription } from "@/components/ui/alert";
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;
element?: string | null; weaponType?: string | null };
type Catalog = { characters: Item[]; weapons: Item[]; artifacts: Item[] };
const blankMember = (): CommissionRequest["teams"][number]["members"][number] => ({
characterKey: "", weaponKey: "", artifactKey: "", constellation: "0", refinement: "1",
});
const blankTeam = () => ({ members: Array.from({ length: 4 }, blankMember) });
function checkoutError(code: string | undefined, status: number): string {
if (status === 401) return "เซสชันหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง";
if (status === 403) return "ไม่สามารถส่งคำขอจากหน้านี้ได้ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง";
if (status === 429) return "ส่งคำขอบ่อยเกินไป กรุณารอสักครู่แล้วลองอีกครั้ง";
if (code === "invalid-promptpay-value" || code === "invalid-promptpay-type" ||
code === "COMMISSION_PROMPTPAY_VALUE-not-configured")
return "ระบบยังตั้งค่าบัญชี PromptPay ไม่ถูกต้อง กรุณาแจ้งผู้ดูแลระบบ";
if (code === "invalid-commission-request") return "รายการที่เลือกไม่ตรงกับข้อมูลล่าสุด กรุณาโหลดหน้าใหม่แล้วเลือกอีกครั้ง";
if (code === "commission-closed") return "ขณะนี้ปิดรับ Commission ใหม่ กรุณาโหลดหน้าเพื่อดูประกาศ";
return "สร้างคำขอไม่สำเร็จ กรุณาลองอีกครั้ง";
}
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 }));
const compatibleWeapons = (characterKey: string) => {
const type = catalog.characters.find((item) => item.key === characterKey)?.weaponType;
return catalog.weapons.filter((item) => type && item.weaponType === type);
};
async function submit() {
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",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(request) });
if (!response.ok) {
const body = await response.json().catch(() => null) as { error?: string } | null;
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);
}
finally { submitting.current = false; setBusy(false); }
}
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">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{team.members.map((member, memberIndex) => {
const character = catalog.characters.find((item) => item.key === member.characterKey);
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} 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} 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} 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) => {
const teams = structuredClone(request.teams); teams[teamIndex].members[memberIndex].artifactKey = artifactKey; patch({ teams });
}} />
</div>
</div>
<div className="mt-1 flex min-w-0 gap-1">
<ConstellationSelect value={member.constellation} disabled={!character}
aria-label={`Constellation ของตัวละครตำแหน่ง ${memberIndex + 1}`}
onValueChange={(constellation) => {
const teams = structuredClone(request.teams);
teams[teamIndex].members[memberIndex].constellation = constellation as typeof member.constellation;
patch({ teams });
}} triggerProps={{ size: "sm", className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden" }} />
<RefinementSelect value={member.refinement} disabled={!member.weaponKey}
aria-label={`Refinement ของอาวุธตำแหน่ง ${memberIndex + 1}`}
onValueChange={(refinement) => {
const teams = structuredClone(request.teams);
teams[teamIndex].members[memberIndex].refinement = refinement as typeof member.refinement;
patch({ teams });
}} 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>
</CardContent></Card>
<Card><CardHeader><CardTitle>เปรียบเทียบอาวุธ - ฿20 ต่ออาวุธ</CardTitle><CardDescription>เลือกตัวละครและอาวุธอย่างน้อย 2 ชิ้น หากไม่มีทีมจะมีค่าบริการพื้นฐาน ฿100</CardDescription></CardHeader><CardContent className="flex flex-col gap-4">
{request.weapons.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 min-w-0 flex-col gap-2">
{set.weaponKeys.map((weaponKey, weaponIndex) => {
return <div key={weaponIndex} className="flex w-full max-w-sm items-center gap-2">
<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} invalid={invalid(`weapons.${index}.weaponKeys.${weaponIndex}`)} label={`เลือกอาวุธเปรียบเทียบชิ้นที่ ${weaponIndex + 1}`}
placeholder={`อาวุธชิ้นที่ ${weaponIndex + 1}`} emptyLabel={set.characterKey ? "ไม่พบอาวุธ" : "เลือกตัวละครก่อน"}
onValueChange={(next) => {
const weapons = structuredClone(request.weapons);
delete weapons[index].refinements?.[weaponKey];
weapons[index].weaponKeys[weaponIndex] = next;
if (next) weapons[index].refinements = { ...weapons[index].refinements, [next]: "1" };
patch({ weapons });
}} />
</div>
<RefinementSelect value={set.refinements?.[weaponKey] ?? "1"} disabled={!weaponKey}
aria-label={`Refinement ของอาวุธเปรียบเทียบชิ้นที่ ${weaponIndex + 1}`}
onValueChange={(refinement) => {
const weapons = structuredClone(request.weapons);
weapons[index].refinements = { ...weapons[index].refinements,
[weaponKey]: refinement as "1" | "2" | "3" | "4" | "5" };
patch({ weapons });
}} triggerProps={{ size: "sm", className: "w-16 shrink-0 px-1 text-xs" }} />
<Button type="button" variant="ghost" size="icon-sm" aria-label={`ลบอาวุธชิ้นที่ ${weaponIndex + 1}`}
disabled={!weaponKey && set.weaponKeys.length <= 1} onClick={() => {
const weapons = structuredClone(request.weapons);
delete weapons[index].refinements?.[weaponKey];
if (set.weaponKeys.length > 1) weapons[index].weaponKeys.splice(weaponIndex, 1);
else weapons[index].weaponKeys[weaponIndex] = "";
patch({ weapons });
}}><XIcon /></Button>
</div>;
})}
</div>
<div className="relative w-20 sm:w-24">
<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) => {
const weapons = structuredClone(request.weapons); weapons[index] = { characterKey, weaponKeys: [""], refinements: {} }; patch({ weapons });
}} />
{set.characterKey ? <Button type="button" variant="secondary" size="icon-xs"
className="absolute top-1 right-1" aria-label={`ลบตัวละครเปรียบเทียบอาวุธชุดที่ ${index + 1}`}
onClick={() => {
const weapons = structuredClone(request.weapons); weapons[index] = { characterKey: "", weaponKeys: [""], refinements: {} }; patch({ weapons });
}}><XIcon /></Button> : null}
<p className="mt-1 truncate text-center text-xs font-medium" title={catalog.characters.find((item) => item.key === set.characterKey)?.name}>
{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>
<Button variant="outline" onClick={() => patch({ weapons: request.weapons.filter((_, i) => i !== index) })}>ลบชุดนี้</Button>
</FieldGroup>)}
<Button variant="outline" onClick={() => patch({ weapons: [...request.weapons, { characterKey: "", weaponKeys: [""], refinements: {} }] })}>เพิ่มชุดอาวุธ</Button>
</CardContent></Card>
<Card><CardHeader><CardTitle>เปรียบเทียบกลุ่มดาว - ฿20 ต่อระดับ</CardTitle><CardDescription>เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ หากไม่มีทีมจะมีค่าบริการพื้นฐาน ฿100</CardDescription></CardHeader><CardContent className="flex flex-col gap-4">
{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 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} 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) => {
const constellations = structuredClone(request.constellations); constellations[index] = { characterKey, levels: [] }; patch({ constellations });
}} />
<p className="mt-1 truncate text-center text-xs font-medium" title={catalog.characters.find((item) => item.key === set.characterKey)?.name}>
{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 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>
<p className="text-xs text-muted-foreground sm:hidden">{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)} ระดับกลุ่มดาว</p>
{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>;
}