231 lines
19 KiB
TypeScript
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>;
|
|
}
|