feat : also update
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user