286 lines
24 KiB
TypeScript
286 lines
24 KiB
TypeScript
"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 className="min-h-11" disabled={!hydrated || busy || commissionPrice(request) === 0} onClick={() => void submit()}>{busy ? "กำลังสร้างคำขอ…" : "ยืนยันและชำระเงิน"}</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
</FieldSet>;
|
||
}
|