Files
buzz-sheet/components/commission/request-form.tsx
T
2026-10-01 21:32:59 +07:00

231 lines
19 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { 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 { Checkbox } from "@/components/ui/checkbox";
import { FieldGroup } from "@/components/ui/field";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { commissionPrice, commissionRequestSchema, type CommissionRequest } from "@/lib/commission/request";
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 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 "ไม่สามารถส่งคำขอจากหน้านี้ได้ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง";
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 }: { catalog: Catalog }) {
const router = useRouter();
const [request, setRequest] = useState<CommissionRequest>({ teams: [blankTeam()], weapons: [], constellations: [] });
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() {
const validationError = requestError(request);
if (validationError) { setError(validationError); toast.error(validationError); return; }
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();
router.push(`/commission/pay/${checkoutId}`);
} catch (cause) {
const message = cause instanceof Error ? cause.message : "สร้างคำขอไม่สำเร็จ";
setError(message);
toast.error(message);
}
finally { setBusy(false); }
}
return <div className="flex flex-col gap-6">
<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}
label={`เลือกตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
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}
label={`เลือกอาวุธของตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาอาวุธที่รองรับ…"
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}
label={`เลือกอาร์ติแฟกต์ของตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาชุดอาร์ติแฟกต์…"
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>
</div>;
})}
</div>
<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} 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}
label={`เลือกตัวละครเปรียบเทียบอาวุธชุดที่ ${index + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
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>
<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 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}
label={`เลือกตัวละครเปรียบเทียบกลุ่มดาวชุดที่ ${index + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
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>
<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>;
}