feat : also update
CI / Verify (push) Successful in 2m4s
CI / Build immutable images and deploy (push) Successful in 3m8s

This commit is contained in:
2026-10-01 19:47:02 +07:00 Unverified
parent 0022625edb
commit 2517982a27
18 changed files with 609 additions and 83 deletions
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import { verificationError } from "@/lib/commission/verification-error";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
export function MobileSlipForm() {
const [amount, setAmount] = useState<number | null>(null);
const [ready, setReady] = useState(false);
const [complete, setComplete] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const value = window.location.hash.slice(1);
Promise.resolve().then(() => {
if (!value) throw new Error("missing upload link");
return fetch("/api/commission/mobile-slip", { headers: { Authorization: `Bearer ${value}` }, cache: "no-store" });
})
.then(async (response) => {
const result = await response.json();
if (!response.ok) throw new Error(result.error);
setAmount(result.amountBaht);
setComplete(result.complete);
setReady(true);
})
.catch(() => setError("ลิงก์อัปโหลดหมดอายุหรือไม่ถูกต้อง กรุณาสร้าง QR ใหม่บนคอมพิวเตอร์"));
}, []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); setBusy(true); setError("");
try {
const response = await fetch("/api/commission/mobile-slip", {
method: "POST", headers: { Authorization: `Bearer ${window.location.hash.slice(1)}` },
body: new FormData(event.currentTarget),
});
const result = await response.json();
if (!response.ok) throw new Error(verificationError(result.error));
setComplete(true);
} catch (cause) { setError(cause instanceof Error ? cause.message : "ตรวจสอบสลิปไม่สำเร็จ"); }
finally { setBusy(false); }
}
if (complete) return <Alert><AlertDescription>ตรวจสอบสลิปสำเร็จแล้ว กลับไปดู Ticket บนคอมพิวเตอร์ได้เลย</AlertDescription></Alert>;
return <div className="flex flex-col gap-4">
{ready && <><p>ยอดชำระ ฿{amount}</p><form onSubmit={submit} className="flex flex-col gap-4">
<Field><FieldLabel htmlFor="mobile-slip">เลือกรูปสลิปจากแอปธนาคาร</FieldLabel>
<Input id="mobile-slip" name="file" type="file" accept="image/png,image/jpeg,image/webp" required disabled={busy} /></Field>
<Button type="submit" disabled={busy}>{busy ? "กำลังตรวจสอบ..." : "ตรวจสอบสลิป"}</Button>
</form></>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
</div>;
}
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { verificationError } from "@/lib/commission/verification-error";
type LinkDetails = { qr: string; digest: string; expiresAt: number };
export function MobileSlipHandoff({ checkoutId }: { checkoutId: string }) {
const router = useRouter();
const [link, setLink] = useState<LinkDetails | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [expired, setExpired] = useState(false);
const [error, setError] = useState("");
const [now, setNow] = useState(Date.now);
const secondsLeft = link ? Math.max(0, Math.ceil((link.expiresAt - now) / 1000)) : 0;
const timeLeft = `${String(Math.floor(secondsLeft / 60)).padStart(2, "0")}:${String(secondsLeft % 60).padStart(2, "0")}`;
async function createLink() {
setBusy(true); setError("");
try {
const response = await fetch(`/api/commission/checkouts/${checkoutId}/upload-link`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(verificationError(result.error));
setLink(result as LinkDetails);
setNow(Date.now());
setExpired(false);
setOpen(true);
} catch (cause) { setError(cause instanceof Error ? cause.message : "สร้าง QR ไม่สำเร็จ"); }
finally { setBusy(false); }
}
useEffect(() => {
if (!link) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [link]);
useEffect(() => {
if (!link) return;
let active = true;
const endpoint = `/api/commission/checkouts/${checkoutId}/upload-link?digest=${link.digest}`;
const check = async () => {
try {
const response = await fetch(endpoint, { cache: "no-store" });
if (!response.ok) return;
const result = await response.json();
if (!active) return;
if (result.state === "complete") router.push(`/commission/tickets/${result.ticketId}`);
else if (result.state === "expired") setExpired(true);
else setError(result.error ? verificationError(result.error) : "");
} catch { /* The next event or reconnect checks again. */ }
};
const source = new EventSource("/api/commission/events");
source.addEventListener("open", check);
source.addEventListener("changed", check);
const timer = window.setTimeout(() => void check(), Math.max(0, link.expiresAt - Date.now() + 500));
void check();
return () => {
active = false;
source.removeEventListener("open", check);
source.removeEventListener("changed", check);
source.close();
window.clearTimeout(timer);
};
}, [checkoutId, link, router]);
return <div className="flex w-full flex-col items-start gap-3">
<Button type="button" variant="link" className="h-auto px-0 text-foreground underline" disabled={busy}
onClick={() => { if (link && !expired && secondsLeft > 0) setOpen(true); else void createLink(); }}>
{busy ? "กำลังสร้าง QR..." : "อัปโหลดจากมือถือ"}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>อัปโหลดสลิปจากมือถือ</DialogTitle>
<DialogDescription>สแกน QR ด้วยมือถือ แล้วเลือกรูปสลิปจากแอปธนาคาร</DialogDescription>
</DialogHeader>
{link && <div className="flex flex-col items-center gap-3">
{!expired && <>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={link.qr} alt="QR สำหรับเปิดหน้าอัปโหลดสลิปบนมือถือ" width={240} height={240} />
<p className="text-center text-sm text-muted-foreground">เวลาที่เหลือ {timeLeft}</p>
</>}
{expired && <p className="text-center text-sm text-muted-foreground">ลิงก์หมดอายุแล้ว ปิดหน้าต่างนี้แล้วกดอัปโหลดจากมือถืออีกครั้ง</p>}
</div>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
</DialogContent>
</Dialog>
{error && !open && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
</div>;
}
+4 -26
View File
@@ -2,35 +2,12 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { MobileSlipHandoff } from "@/components/commission/mobile-slip-handoff";
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}) กรุณาแจ้งผู้ดูแลระบบ`
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";
}
}
import { verificationError } from "@/lib/commission/verification-error";
export function CommissionPaymentForm({ checkoutId }: { checkoutId: string }) {
const router = useRouter();
@@ -50,7 +27,8 @@ export function CommissionPaymentForm({ checkoutId }: { checkoutId: string }) {
}
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>
<Input id="slip" name="file" type="file" accept="image/png,image/jpeg,image/webp" required />
<MobileSlipHandoff checkoutId={checkoutId} /></Field>
<Button type="submit" disabled={busy}>{busy ? "กำลังตรวจสอบ..." : "ตรวจสอบสลิปและเปิด Ticket"}</Button>
</FieldGroup>{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}</form>;
}
+24 -6
View File
@@ -3,6 +3,7 @@
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";
@@ -62,7 +63,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
async function submit() {
const validationError = requestError(request);
if (validationError) { setError(validationError); return; }
if (validationError) { setError(validationError); toast.error(validationError); return; }
setBusy(true); setError("");
try {
const response = await fetch("/api/commission/checkouts", { method: "POST",
@@ -73,7 +74,11 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
}
const { checkoutId } = await response.json();
router.push(`/commission/pay/${checkoutId}`);
} catch (cause) { setError(cause instanceof Error ? cause.message : "สร้างคำขอไม่สำเร็จ"); }
} catch (cause) {
const message = cause instanceof Error ? cause.message : "สร้างคำขอไม่สำเร็จ";
setError(message);
toast.error(message);
}
finally { setBusy(false); }
}
@@ -142,12 +147,25 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
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 });
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 });
@@ -159,12 +177,12 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
<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 });
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: [""] }; patch({ weapons });
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>
@@ -175,7 +193,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
}}>เพิ่มอาวุธ</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>
<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">
+3 -1
View File
@@ -71,7 +71,9 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
<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>
return weapon ? <div key={key} className="w-16 shrink-0"><ItemImage item={weapon} />
<div className="mt-1 flex justify-center"><Badge variant="secondary">R{set.refinements?.[key] ?? "1"}</Badge></div>
<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>