feat : big update
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function CommissionLiveRefresh({ endpoint }: { endpoint: string }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const source = new EventSource(endpoint);
|
||||
const refresh = () => router.refresh();
|
||||
source.addEventListener("changed", refresh);
|
||||
source.addEventListener("open", refresh);
|
||||
return () => { source.removeEventListener("changed", refresh); source.removeEventListener("open", refresh); source.close(); };
|
||||
}, [endpoint, router]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function verificationError(code: string | undefined): string {
|
||||
const reason = code?.startsWith("slip-rejected:") ? code.slice("slip-rejected:".length)
|
||||
: code?.startsWith("slip2go-service:") ? code.slice("slip2go-service:".length) : code;
|
||||
switch (reason) {
|
||||
case "200000": return "พบสลิป แต่ Slip2Go ยังไม่ได้ยืนยันเงื่อนไขผู้รับและยอดเงิน กรุณาแจ้งผู้ดูแลระบบ (200000)";
|
||||
case "200401": return "บัญชีผู้รับในสลิปไม่ตรงกับบัญชีที่ตั้งไว้ (200401)";
|
||||
case "200402": return "ยอดเงินในสลิปไม่ตรงกับยอดคำขอ (200402)";
|
||||
case "200403": return "วันที่โอนในสลิปไม่ตรงเงื่อนไข (200403)";
|
||||
case "200404": return "Slip2Go ไม่พบข้อมูลสลิปในระบบธนาคาร (200404)";
|
||||
case "200500": return "Slip2Go ระบุว่าสลิปไม่ถูกต้อง (200500)";
|
||||
case "200501": return "สลิปนี้เคยถูกตรวจสอบแล้ว หากชำระเงินแล้ว กรุณาติดต่อผู้ดูแลก่อนโอนซ้ำ (200501)";
|
||||
case "200502": return "ธนาคารไม่สามารถตรวจสอบสลิปได้ในขณะนี้ กรุณาลองอีกครั้ง (200502)";
|
||||
case "400001":
|
||||
case "400002": return `อ่าน QR ในรูปสลิปไม่ได้ กรุณาอัปโหลดรูปสลิปจากแอปธนาคาร (${reason})`;
|
||||
case "400400": return "ตั้งค่าเงื่อนไขตรวจสอบสลิปไม่ถูกต้อง กรุณาแจ้งผู้ดูแลระบบ (400400)";
|
||||
case "401001": return "Slip2Go ไม่ยอมรับ API Secret ที่ตั้งไว้ กรุณาแจ้งผู้ดูแลระบบ (401001)";
|
||||
case "401005": return "โทเคน Slip2Go หมด กรุณาแจ้งผู้ดูแลระบบ (401005)";
|
||||
case "slip-amount-or-date-mismatch": return "ยอดเงินหรือวันเวลาในสลิปไม่ตรงกับคำขอนี้ กรุณาใช้สลิปจากการชำระเงินครั้งนี้";
|
||||
case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว";
|
||||
default: return reason && /^\d{6}$/.test(reason)
|
||||
? `ตรวจสอบสลิปไม่ผ่าน (รหัส ${reason}) กรุณาแจ้งผู้ดูแลระบบ`
|
||||
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";
|
||||
}
|
||||
}
|
||||
|
||||
export function CommissionPaymentForm({ checkoutId }: { checkoutId: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); setBusy(true); setError("");
|
||||
try {
|
||||
const response = await fetch(`/api/commission/checkouts/${checkoutId}/verify`, {
|
||||
method: "POST", body: new FormData(event.currentTarget),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(verificationError(result.error));
|
||||
router.push(`/commission/tickets/${result.ticketId}`);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "เกิดข้อผิดพลาด"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <form onSubmit={submit} className="flex flex-col gap-4"><FieldGroup>
|
||||
<Field><FieldLabel htmlFor="slip">อัปโหลดสลิปที่ชำระเงินแล้ว</FieldLabel>
|
||||
<Input id="slip" name="file" type="file" accept="image/png,image/jpeg,image/webp" required /></Field>
|
||||
<Button type="submit" disabled={busy}>{busy ? "กำลังตรวจสอบ..." : "ตรวจสอบสลิปและเปิด Ticket"}</Button>
|
||||
</FieldGroup>{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}</form>;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { 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, 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 "รายการที่เลือกไม่ตรงกับข้อมูลล่าสุด กรุณาโหลดหน้าใหม่แล้วเลือกอีกครั้ง";
|
||||
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); 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) { setError(cause instanceof Error ? cause.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); weapons[index].weaponKeys[weaponIndex] = next; patch({ weapons });
|
||||
}} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`ลบอาวุธชิ้นที่ ${weaponIndex + 1}`}
|
||||
disabled={!weaponKey && set.weaponKeys.length <= 1} onClick={() => {
|
||||
const weapons = structuredClone(request.weapons);
|
||||
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: [""] }; 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: [""] }; 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: [""] }] })}>เพิ่มชุดอาวุธ</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>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Image from "next/image";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { elementBackgroundColor } from "@/lib/catalog/element";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import type { CommissionRequest } from "@/lib/commission/request";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Item = { key: string; name: string; imageUrl: string; rarity?: number | null; element?: string | null };
|
||||
type Catalog = { characters: Item[]; weapons: Item[]; artifacts: Item[] };
|
||||
|
||||
function ItemImage({ item, size = "small" }: { item: Item; size?: "small" | "large" }) {
|
||||
return <div title={item.name} className={cn("relative aspect-square min-w-0 overflow-hidden rounded-lg", rarityGradientClass(item.rarity))}>
|
||||
<Image src={item.imageUrl} alt={item.name} fill sizes={size === "large" ? "160px" : "64px"} className="object-contain" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CommissionTeamCard({ team, index, catalog }: { team: CommissionRequest["teams"][number]; index: number; catalog: Catalog }) {
|
||||
const characters = new Map(catalog.characters.map((item) => [item.key, item]));
|
||||
const weapons = new Map(catalog.weapons.map((item) => [item.key, item]));
|
||||
const artifacts = new Map(catalog.artifacts.map((item) => [item.key, item]));
|
||||
return <div className="@container"><Card size="sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle>ทีม {index + 1}</CardTitle><Badge variant="secondary">฿100</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-4 gap-1 lg:grid-cols-2 lg:gap-2 lg:@min-[560px]:grid-cols-4">
|
||||
{team.members.map((member, memberIndex) => {
|
||||
const character = characters.get(member.characterKey);
|
||||
const weapon = weapons.get(member.weaponKey);
|
||||
const artifact = artifacts.get(member.artifactKey);
|
||||
return <div key={memberIndex} className="min-w-0 rounded-lg p-1 lg:p-1.5" style={{ backgroundColor: elementBackgroundColor(character?.element) }}>
|
||||
<div className="grid grid-cols-1 gap-1 lg:grid-cols-[2fr_1fr]">
|
||||
{character ? <ItemImage item={character} size="large" /> : <div className="aspect-square rounded-lg bg-muted" />}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-0.5 lg:grid-cols-1 lg:grid-rows-2 lg:gap-1">
|
||||
{weapon ? <ItemImage item={weapon} /> : <div title={member.weaponKey} className="aspect-square rounded-lg bg-muted" />}
|
||||
{artifact ? <ItemImage item={artifact} /> : <div title={member.artifactKey} className="aspect-square rounded-lg bg-muted" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-0.5 lg:flex-nowrap lg:gap-1">
|
||||
<Badge variant="secondary" className="h-4 px-0.5 text-[9px] lg:h-auto lg:px-2 lg:text-xs">C{member.constellation ?? "0"}</Badge>
|
||||
<Badge variant="secondary" className="h-4 px-0.5 text-[9px] lg:h-auto lg:px-2 lg:text-xs">R{member.refinement ?? "1"}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-center text-[10px] font-medium lg:text-xs" title={character?.name}>{character?.name ?? member.characterKey}</p>
|
||||
</div>;
|
||||
})}
|
||||
</CardContent>
|
||||
</Card></div>;
|
||||
}
|
||||
|
||||
export function CommissionRequestSummary({ request, catalog, amountBaht }: {
|
||||
request: CommissionRequest; catalog: Catalog; amountBaht: number;
|
||||
}) {
|
||||
const characters = new Map(catalog.characters.map((item) => [item.key, item]));
|
||||
const weapons = new Map(catalog.weapons.map((item) => [item.key, item]));
|
||||
const comparisonTotal = request.weapons.reduce((sum, set) => sum + set.weaponKeys.length * 20, 0) +
|
||||
request.constellations.reduce((sum, set) => sum + set.levels.length * 20, 0);
|
||||
|
||||
return <div className="@container flex flex-col gap-4">
|
||||
{request.teams.length === 0 && amountBaht > comparisonTotal ?
|
||||
<Card size="sm"><CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle>ค่าบริการพื้นฐาน</CardTitle><Badge variant="secondary">฿100</Badge>
|
||||
</CardHeader></Card> : null}
|
||||
{request.teams.map((team, index) => <CommissionTeamCard key={`team-${index}`} team={team} index={index} catalog={catalog} />)}
|
||||
{request.weapons.map((set, index) => {
|
||||
const character = characters.get(set.characterKey);
|
||||
return <Card key={`weapon-${index}`} size="sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle>เปรียบเทียบอาวุธ {index + 1}</CardTitle><Badge variant="secondary">฿{set.weaponKeys.length * 20}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-[minmax(0,1fr)_64px] items-start gap-3">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{set.weaponKeys.map((key) => {
|
||||
const weapon = weapons.get(key);
|
||||
return weapon ? <div key={key} className="w-16 shrink-0"><ItemImage item={weapon} /><p className="mt-1 truncate text-center text-xs" title={weapon.name}>{weapon.name}</p></div>
|
||||
: <p key={key} className="w-16 wrap-break-word text-xs">{key}</p>;
|
||||
})}
|
||||
</div>
|
||||
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
})}
|
||||
{request.constellations.map((set, index) => {
|
||||
const character = characters.get(set.characterKey);
|
||||
return <Card key={`constellation-${index}`} size="sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle>เปรียบเทียบกลุ่มดาว {index + 1}</CardTitle><Badge variant="secondary">฿{set.levels.length * 20}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-[minmax(0,1fr)_64px] items-center gap-3">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">{set.levels.map((level) => <Badge key={level} variant="outline">C{level}</Badge>)}</div>
|
||||
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOutIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export function CommissionSignOut() {
|
||||
const router = useRouter();
|
||||
return <Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
|
||||
await authClient.signOut();
|
||||
router.push("/commission");
|
||||
router.refresh();
|
||||
}}><LogOutIcon /></Button>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function CommissionSlipDialog({ ticketId }: { ticketId: string }) {
|
||||
const [imageState, setImageState] = useState<"loading" | "loaded" | "error">("loading");
|
||||
|
||||
return <Dialog>
|
||||
<DialogTrigger render={<Button type="button" variant="outline" size="sm" className="w-fit" />}>
|
||||
ดูสลิป
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90svh] max-w-[calc(100%-2rem)] sm:max-w-3xl">
|
||||
<DialogTitle>สลิปการชำระเงิน</DialogTitle>
|
||||
{imageState === "loading" && <Skeleton className="h-80 w-full" aria-label="กำลังโหลดสลิป" />}
|
||||
{imageState === "error" && <p className="text-sm text-destructive">โหลดสลิปไม่สำเร็จ</p>}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={`/api/commission/tickets/${ticketId}/slip`} alt="สลิปการชำระเงิน"
|
||||
onLoad={() => setImageState("loaded")} onError={() => setImageState("error")}
|
||||
className={imageState === "loaded" ? "max-h-[calc(90svh-7rem)] w-full object-contain" : "hidden"} />
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ClipboardEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ImagePlus, MessageCircle, SendHorizontal, SmilePlus, Trash2, X } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
|
||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bubble, BubbleContent } from "@/components/ui/bubble";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader } from "@/components/ui/message";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
|
||||
imageObjectKey: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
|
||||
type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrl: string | null;
|
||||
status: "sending" | "sent" };
|
||||
type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
|
||||
const emojiChoices = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
|
||||
const reactionKey = (messageId: string, emoji: string) => `${messageId}:${emoji}`;
|
||||
|
||||
function CommissionMessageImage({ src }: { src: string }) {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [state, setState] = useState<"loading" | "loaded" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
if (imageRef.current?.complete)
|
||||
setState(imageRef.current.naturalWidth > 0 ? "loaded" : "error");
|
||||
}, [src]);
|
||||
|
||||
return <Dialog>
|
||||
<DialogTrigger render={<button type="button" disabled={state !== "loaded"}
|
||||
className="block max-w-full cursor-zoom-in overflow-hidden rounded-lg disabled:cursor-default" aria-label="ขยายรูปภาพที่แนบ" />}>
|
||||
{state === "loading" && <Skeleton className="h-40 w-48 max-w-full rounded-lg" aria-label="กำลังโหลดรูปภาพ" />}
|
||||
{state === "error" && <span className="block px-4 py-2.5 text-xs">โหลดรูปภาพไม่สำเร็จ</span>}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img ref={imageRef} src={src} alt="รูปภาพที่แนบในข้อความ"
|
||||
onLoad={() => setState("loaded")} onError={() => setState("error")}
|
||||
className={cn("max-h-72 max-w-full object-contain", state === "loaded" ? "block" : "hidden")} />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-fit max-h-[90svh] max-w-[calc(100%-2rem)] p-2 sm:max-w-[calc(100%-2rem)]">
|
||||
<DialogTitle className="sr-only">รูปภาพที่แนบในข้อความ</DialogTitle>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt="รูปภาพที่แนบในข้อความขนาดใหญ่" className="max-h-[calc(90svh-1rem)] max-w-full object-contain" />
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function CommissionTicketChat({ ticketId, userId, status, messages, admin }: {
|
||||
ticketId: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const stayAtBottom = useRef(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const imagePreviewRef = useRef<string | null>(null);
|
||||
const optimisticImageUrls = useRef(new Set<string>());
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
|
||||
const nextOptimisticId = useRef(0);
|
||||
const [reactionFor, setReactionFor] = useState<string | null>(null);
|
||||
const [optimisticReactions, setOptimisticReactions] = useState<Record<string, OptimisticReaction>>({});
|
||||
const pendingReactions = useRef(new Set<string>());
|
||||
const [deleteFor, setDeleteFor] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(() => new Set());
|
||||
const [typing, setTyping] = useState<{ userId: string; name: string } | null>(null);
|
||||
const lastTypingSent = useRef(0);
|
||||
const typingActive = useRef(false);
|
||||
const sendingRef = useRef(false);
|
||||
const deferredRefresh = useRef(false);
|
||||
const visibleOptimisticMessages = optimisticMessages.filter((message) => !message.serverId ||
|
||||
!messages.some((saved) => saved.id === message.serverId));
|
||||
const visibleMessages = messages.filter((message) => !deletedMessageIds.has(message.id));
|
||||
|
||||
useEffect(() => () => {
|
||||
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
|
||||
for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
for (const message of optimisticMessages) {
|
||||
if (message.serverId && messages.some((saved) => saved.id === message.serverId) &&
|
||||
message.imageUrl && optimisticImageUrls.current.has(message.imageUrl)) {
|
||||
URL.revokeObjectURL(message.imageUrl);
|
||||
optimisticImageUrls.current.delete(message.imageUrl);
|
||||
}
|
||||
}
|
||||
}, [messages, optimisticMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`);
|
||||
let typingTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const refresh = () => {
|
||||
if (sendingRef.current) deferredRefresh.current = true;
|
||||
else router.refresh();
|
||||
};
|
||||
const receive = (event: Event) => {
|
||||
const value = (event as MessageEvent<string>).data;
|
||||
if (!value.startsWith("typing:")) { refresh(); return; }
|
||||
try {
|
||||
const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean };
|
||||
if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return;
|
||||
if (typingTimeout) clearTimeout(typingTimeout);
|
||||
if (update.active) {
|
||||
setTyping({ userId: update.userId, name: update.name });
|
||||
typingTimeout = setTimeout(() => setTyping(null), 6000);
|
||||
} else setTyping((current) => current?.userId === update.userId ? null : current);
|
||||
} catch { /* Ignore malformed transient signals. */ }
|
||||
};
|
||||
source.addEventListener("changed", receive);
|
||||
source.addEventListener("open", refresh);
|
||||
return () => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout);
|
||||
source.removeEventListener("changed", receive);
|
||||
source.removeEventListener("open", refresh);
|
||||
source.close();
|
||||
};
|
||||
}, [ticketId, userId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollRef.current;
|
||||
if (viewport && stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [messages, optimisticMessages]);
|
||||
|
||||
function sendTyping(active: boolean) {
|
||||
if (status !== "open") return;
|
||||
const now = Date.now();
|
||||
if (active && typingActive.current && now - lastTypingSent.current < 4000) return;
|
||||
if (!active && !typingActive.current) return;
|
||||
typingActive.current = active;
|
||||
if (active) lastTypingSent.current = now;
|
||||
void fetch(`/api/commission/tickets/${ticketId}/typing`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active }),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
function setAttachment(file: File | null) {
|
||||
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
|
||||
const preview = file ? URL.createObjectURL(file) : null;
|
||||
imagePreviewRef.current = preview;
|
||||
setImage(file);
|
||||
setImagePreview(preview);
|
||||
}
|
||||
|
||||
function chooseAttachment(file: File | null) {
|
||||
if (file && (file.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))) {
|
||||
setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setAttachment(file);
|
||||
}
|
||||
|
||||
function handleComposerPaste(event: ClipboardEvent<HTMLTextAreaElement>) {
|
||||
const pastedImage = Array.from(event.clipboardData.items)
|
||||
.find((item) => item.kind === "file" && item.type.startsWith("image/"))?.getAsFile();
|
||||
if (!pastedImage) return;
|
||||
event.preventDefault();
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
chooseAttachment(pastedImage);
|
||||
}
|
||||
|
||||
async function send(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy || (!text.trim() && !image)) return;
|
||||
setBusy(true); setError("");
|
||||
sendingRef.current = true;
|
||||
const form = event.currentTarget;
|
||||
const originalText = text;
|
||||
const originalImage = image;
|
||||
const localId = String(++nextOptimisticId.current);
|
||||
const imageUrl = image ? URL.createObjectURL(image) : null;
|
||||
if (imageUrl) optimisticImageUrls.current.add(imageUrl);
|
||||
setOptimisticMessages((current) => [...current.filter((message) => !message.serverId ||
|
||||
!messages.some((saved) => saved.id === message.serverId)), {
|
||||
localId, serverId: null, text: text.trim(), imageUrl, status: "sending",
|
||||
}]);
|
||||
stayAtBottom.current = true;
|
||||
try {
|
||||
const data = new FormData(form);
|
||||
data.set("text", text.trim());
|
||||
if (image) data.set("image", image);
|
||||
else data.delete("image");
|
||||
form.reset(); setText(""); setAttachment(null); sendTyping(false);
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/messages`, { method: "POST", body: data });
|
||||
if (!response.ok) throw new Error("ส่งข้อความไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
const result = await response.json() as { messageId: string };
|
||||
setOptimisticMessages((current) => current.map((message) => message.localId === localId ?
|
||||
{ ...message, serverId: result.messageId, status: "sent" } : message));
|
||||
stayAtBottom.current = true;
|
||||
sendingRef.current = false; deferredRefresh.current = false; router.refresh();
|
||||
} catch (cause) {
|
||||
if (imageUrl) { URL.revokeObjectURL(imageUrl); optimisticImageUrls.current.delete(imageUrl); }
|
||||
setOptimisticMessages((current) => current.filter((message) => message.localId !== localId));
|
||||
setText(originalText); setAttachment(originalImage);
|
||||
setError(cause instanceof Error ? cause.message : "ส่งข้อความไม่สำเร็จ");
|
||||
sendingRef.current = false;
|
||||
if (deferredRefresh.current) { deferredRefresh.current = false; router.refresh(); }
|
||||
}
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function react(messageId: string, emoji: string) {
|
||||
setReactionFor(null);
|
||||
const key = reactionKey(messageId, emoji);
|
||||
if (pendingReactions.current.has(key)) return;
|
||||
const message = messages.find((item) => item.id === messageId);
|
||||
if (!message) return;
|
||||
const savedActive = message.reactions.some((item) => item.emoji === emoji && item.userId === userId);
|
||||
const previous = optimisticReactions[key];
|
||||
const currentActive = previous?.status === "sent" && previous.active === savedActive
|
||||
? savedActive : previous?.active ?? savedActive;
|
||||
pendingReactions.current.add(key);
|
||||
setError("");
|
||||
setOptimisticReactions((current) => ({ ...current, [key]: { active: !currentActive, status: "sending" } }));
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/reactions`, { method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messageId, emoji }) });
|
||||
if (!response.ok) throw new Error();
|
||||
const result = await response.json() as { active: boolean };
|
||||
setOptimisticReactions((current) => ({ ...current, [key]: { active: result.active, status: "sent" } }));
|
||||
router.refresh();
|
||||
} catch {
|
||||
setOptimisticReactions((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setError("เปลี่ยนรีแอ็กชันไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
} finally { pendingReactions.current.delete(key); }
|
||||
}
|
||||
|
||||
async function deleteMessage(messageId: string) {
|
||||
if (deletingId) return;
|
||||
setDeleteFor(null); setDeletingId(messageId); setError("");
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/messages/${messageId}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error();
|
||||
setDeletedMessageIds((current) => new Set(current).add(messageId));
|
||||
setReactionFor(null);
|
||||
router.refresh();
|
||||
} catch { setError("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
||||
finally { setDeletingId(null); }
|
||||
}
|
||||
|
||||
async function toggleStatus() {
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/status`, { method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: status === "open" ? "closed" : "open" }) });
|
||||
if (!response.ok) throw new Error();
|
||||
router.refresh();
|
||||
} catch { setError("เปลี่ยนสถานะ Ticket ไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
||||
}
|
||||
|
||||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
return <Card className="h-svh min-w-0 gap-0 py-0 lg:h-[min(85svh,900px)]">
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
|
||||
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
|
||||
<CardAction className="flex items-center gap-2">
|
||||
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
|
||||
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
|
||||
</Button>}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent ref={scrollRef} role="log" aria-label="ข้อความใน Ticket" aria-live="polite"
|
||||
onScroll={(event) => { const viewport = event.currentTarget;
|
||||
stayAtBottom.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; }}
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain bg-muted/20 px-3 py-5 sm:px-5">
|
||||
{visibleMessages.length === 0 && visibleOptimisticMessages.length === 0 ? <Empty className="h-full">
|
||||
<EmptyHeader><EmptyMedia variant="icon"><MessageCircle /></EmptyMedia>
|
||||
<EmptyTitle>เริ่มบทสนทนา</EmptyTitle>
|
||||
<EmptyDescription>ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty> : <MessageGroup className="gap-5">
|
||||
{visibleMessages.map((message) => {
|
||||
const mine = message.authorId === userId;
|
||||
const sentAt = new Date(message.createdAt);
|
||||
const counts = emojiChoices.map((emoji) => {
|
||||
const savedCount = message.reactions.filter((reaction) => reaction.emoji === emoji).length;
|
||||
const savedActive = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.userId === userId);
|
||||
const update = optimisticReactions[reactionKey(message.id, emoji)];
|
||||
const visibleUpdate = update && (update.status === "sending" || update.active !== savedActive) ? update : null;
|
||||
const active = visibleUpdate?.active ?? savedActive;
|
||||
const count = savedCount + (visibleUpdate && active !== savedActive ? active ? 1 : -1 : 0);
|
||||
return { emoji, count, active, pending: update?.status === "sending" };
|
||||
}).filter(({ count }) => count > 0);
|
||||
return <Message key={message.id} align={mine ? "end" : "start"}>
|
||||
{!mine && <MessageAvatar><Avatar size="sm"><AvatarFallback>
|
||||
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
|
||||
</AvatarFallback></Avatar></MessageAvatar>}
|
||||
<MessageContent className={cn("w-auto max-w-[min(82%,36rem)] gap-1", mine ? "items-end" : "items-start")}>
|
||||
<MessageHeader className="px-1">{mine ? "คุณ" : message.authorName}</MessageHeader>
|
||||
<Bubble variant={message.imageObjectKey ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full">
|
||||
<BubbleContent className={cn("max-w-full", message.imageObjectKey ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm", mine ? "rounded-br-md" : "rounded-bl-md")}>
|
||||
{message.text && <p className={cn("whitespace-pre-wrap", mine && "text-foreground", message.imageObjectKey && "pb-2")}>{message.text}</p>}
|
||||
{message.imageObjectKey && <CommissionMessageImage src={`/api/commission/tickets/${ticketId}/images/${message.id}`} />}
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
{counts.length > 0 && <div className={cn("flex flex-wrap gap-1", mine && "justify-end")}>
|
||||
{counts.map(({ emoji, count, active, pending }) => <Button key={emoji} type="button" size="xs"
|
||||
variant={active ? "secondary" : "outline"} disabled={status !== "open" || pending}
|
||||
aria-label={`${emoji} ${count} คน`} aria-pressed={active}
|
||||
onClick={() => react(message.id, emoji)}><span className={pending ? "text-muted-foreground" : "text-foreground"}>
|
||||
{emoji} {count}
|
||||
</span></Button>)}
|
||||
</div>}
|
||||
<MessageFooter className="gap-1 px-1">
|
||||
<time dateTime={sentAt.toISOString()} title={sentAt.toLocaleString("th-TH")}>
|
||||
{sentAt.toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit" })}
|
||||
</time>
|
||||
{status === "open" && <Popover open={reactionFor === message.id}
|
||||
onOpenChange={(open) => setReactionFor(open ? message.id : null)}>
|
||||
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon-xs" aria-label="เพิ่มรีแอ็กชัน" />}>
|
||||
<SmilePlus />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={mine ? "end" : "start"} className="w-auto">
|
||||
<div className="flex gap-1" aria-label="เลือกรีแอ็กชัน">
|
||||
{emojiChoices.map((emoji) => <Button key={emoji} type="button" variant="ghost" size="icon-sm"
|
||||
aria-label={`ตอบสนอง ${emoji}`} disabled={optimisticReactions[reactionKey(message.id, emoji)]?.status === "sending"}
|
||||
onClick={() => react(message.id, emoji)}>{emoji}</Button>)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>}
|
||||
{mine && <AlertDialog open={deleteFor === message.id}
|
||||
onOpenChange={(open) => setDeleteFor(open ? message.id : null)}>
|
||||
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-xs"
|
||||
aria-label="ลบข้อความ" disabled={Boolean(deletingId)} />}><Trash2 /></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>ลบข้อความนี้?</AlertDialogTitle>
|
||||
<AlertDialogDescription>ข้อความและรูปภาพที่แนบจะถูกลบถาวร</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void deleteMessage(message.id)}>ลบข้อความ</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>}
|
||||
</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>;
|
||||
})}
|
||||
{visibleOptimisticMessages.map((message) => <Message key={message.localId} align="end">
|
||||
<MessageContent className="w-auto max-w-[min(82%,36rem)] items-end gap-1">
|
||||
<MessageHeader className="px-1">คุณ</MessageHeader>
|
||||
<Bubble variant={message.imageUrl ? "ghost" : "muted"} className="max-w-full">
|
||||
<BubbleContent className={cn("max-w-full", message.imageUrl ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm")}>
|
||||
{message.text && <p className={cn("whitespace-pre-wrap", message.status === "sending" ? "text-muted-foreground" : "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
|
||||
{message.imageUrl && <div className={cn(message.status === "sending" && "opacity-50")}>
|
||||
<CommissionMessageImage src={message.imageUrl} />
|
||||
</div>}
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter className="gap-1 px-1">{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>)}
|
||||
</MessageGroup>}
|
||||
</CardContent>
|
||||
<CardFooter className="block bg-card p-3 sm:p-4">
|
||||
{typing && status === "open" && <p role="status" className="mb-2 text-xs text-muted-foreground">{typing.name} typing...</p>}
|
||||
{status === "open" ? <form onSubmit={send}><FieldGroup className="gap-2">
|
||||
{image && <div className="relative w-fit rounded-lg border bg-muted/50 p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{imagePreview && <img src={imagePreview} alt="ตัวอย่างรูปภาพที่จะแนบ" className="size-28 rounded-md bg-muted object-contain" />}
|
||||
<Button type="button" variant="secondary" size="icon-xs" aria-label="นำรูปภาพออก"
|
||||
className="absolute top-1 right-1 shadow-sm"
|
||||
onClick={() => { setAttachment(null); if (fileRef.current) fileRef.current.value = ""; }}><X /></Button>
|
||||
</div>}
|
||||
<Field><FieldLabel htmlFor="commission-message" className="sr-only">ข้อความ</FieldLabel>
|
||||
<InputGroup className="min-h-12 rounded-2xl">
|
||||
<InputGroupAddon align="inline-start" className="self-end pb-1.5">
|
||||
<InputGroupButton type="button" variant="ghost" size="icon-sm" disabled={busy}
|
||||
aria-label="แนบรูปภาพ" onClick={() => fileRef.current?.click()}><ImagePlus /></InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
<InputGroupTextarea id="commission-message" name="text" value={text}
|
||||
onChange={(event) => { const next = event.target.value; setText(next); sendTyping(Boolean(next.trim())); }}
|
||||
onBlur={() => sendTyping(false)} onKeyDown={handleComposerKeyDown} onPaste={handleComposerPaste}
|
||||
placeholder="พิมพ์ข้อความ..." maxLength={4000} disabled={busy} aria-describedby="commission-message-hint"
|
||||
className="max-h-32 min-h-11 min-w-0 px-1.5 py-2.5" />
|
||||
<InputGroupAddon align="inline-end" className="self-end pb-1.5">
|
||||
<InputGroupButton type="submit" variant="default" size="sm"
|
||||
aria-label={busy ? "กำลังส่งข้อความ" : "ส่งข้อความ"} disabled={busy || (!text.trim() && !image)}>
|
||||
<SendHorizontal data-icon="inline-start" /><span className="hidden sm:inline">{busy ? "กำลังส่ง..." : "ส่ง"}</span>
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<span id="commission-message-hint" className="sr-only">กด Enter เพื่อส่ง หรือ Shift+Enter เพื่อขึ้นบรรทัดใหม่</span>
|
||||
</Field>
|
||||
<input ref={fileRef} id="commission-image" name="image" type="file" aria-label="แนบรูปภาพ"
|
||||
className="sr-only" accept="image/png,image/jpeg,image/webp" onChange={(event) => {
|
||||
const selected = event.target.files?.[0] ?? null;
|
||||
chooseAttachment(selected);
|
||||
if (selected && (selected.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(selected.type))) event.target.value = "";
|
||||
}} />
|
||||
</FieldGroup></form> : <p className="text-center text-sm text-muted-foreground">Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket อีกครั้ง</p>}
|
||||
{error && <Alert variant="destructive" className="mt-3"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
</CardFooter>
|
||||
</Card>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCommissionLabels } from "@/lib/commission/catalog";
|
||||
import { getCommissionTicket } from "@/lib/commission/tickets";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
import { CommissionRequestSummary } from "./request-summary";
|
||||
import { CommissionTicketChat } from "./ticket-chat";
|
||||
import { CommissionTicketRename } from "./ticket-rename";
|
||||
import { CommissionSlipDialog } from "./slip-dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export async function CommissionTicketDetail({ id, admin }: { id: string; admin: boolean }) {
|
||||
let data: Awaited<ReturnType<typeof getCommissionTicket>>;
|
||||
try {
|
||||
data = await getCommissionTicket(id);
|
||||
} catch (cause) {
|
||||
if (cause instanceof HttpError && (cause.status === 401 || cause.status === 403 || cause.status === 404))
|
||||
notFound();
|
||||
throw cause;
|
||||
}
|
||||
const catalog = await getCommissionLabels(data.checkout.request);
|
||||
return <div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0"><h1 className="wrap-anywhere font-heading text-3xl font-semibold">
|
||||
{data.ticket.title || `Ticket #${data.ticket.id.slice(0, 8)}`}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{data.ticket.title ? `#${data.ticket.id.slice(0, 8)} - ` : ""}{data.customerName} - {data.ticket.status === "open" ? "เปิด" : "ปิด"}</p>
|
||||
</div>
|
||||
{admin && <CommissionTicketRename ticketId={id} title={data.ticket.title} />}
|
||||
</header>
|
||||
<div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)]">
|
||||
<Card className="h-fit lg:max-h-[78svh] lg:overflow-y-auto"><CardHeader><CardTitle>คำขอที่ชำระเงินแล้ว - ฿{data.checkout.amountBaht}</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">{catalog && <CommissionRequestSummary request={data.checkout.request} catalog={catalog} amountBaht={data.checkout.amountBaht} />}
|
||||
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
|
||||
<CommissionSlipDialog ticketId={id} />
|
||||
</CardContent></Card>
|
||||
<CommissionTicketChat ticketId={id} userId={data.currentUserId} status={data.ticket.status} messages={data.messages} admin={admin} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function CommissionTicketRename({ ticketId, title }: { ticketId: string; title: string | null }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(title ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function changeOpen(next: boolean) {
|
||||
if (busy) return;
|
||||
setOpen(next);
|
||||
if (next) { setValue(title ?? ""); setError(""); }
|
||||
}
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/title`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: value }),
|
||||
});
|
||||
if (!response.ok) throw new Error();
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch { setError("เปลี่ยนชื่อ Ticket ไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogTrigger render={<Button type="button" variant="outline" size="sm" aria-label="เปลี่ยนชื่อ Ticket" />}>
|
||||
<Pencil data-icon="inline-start" /> เปลี่ยนชื่อ
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={save} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DialogTitle>เปลี่ยนชื่อ Ticket</DialogTitle>
|
||||
<DialogDescription>ชื่อนี้จะแสดงให้ลูกค้าเห็น เว้นว่างเพื่อใช้หมายเลข Ticket เดิม</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="commission-ticket-title">ชื่อ Ticket</Label>
|
||||
<Input id="commission-ticket-title" value={value} onChange={(event) => setValue(event.target.value)}
|
||||
maxLength={80} placeholder="เช่น ทีมสำหรับ Spiral Abyss" autoFocus disabled={busy} />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => changeOpen(false)} disabled={busy}>ยกเลิก</Button>
|
||||
<Button type="submit" disabled={busy}>{busy ? "กำลังบันทึก..." : "บันทึก"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
Reference in New Issue
Block a user