Member System
This commit is contained in:
141
src/app/(public)/member/exchange/page.tsx
Normal file
141
src/app/(public)/member/exchange/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { genIdempotencyKey } from "@/server/middleware/idempotency";
|
||||||
|
|
||||||
|
const USE_MOCK3 = (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_USE_MOCK === "1") || true;
|
||||||
|
const MOCK_DELAY_MS3 = 400;
|
||||||
|
const sleep3 = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
type Offer = { id: string; title: string; pointsCost: number; short?: string; available?: boolean };
|
||||||
|
|
||||||
|
type Summary = { pointsBalance: number; offers: Offer[] };
|
||||||
|
|
||||||
|
type ExchangeResponse = { balance: number; voucherId: string; voucherCode: string };
|
||||||
|
|
||||||
|
type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
|
||||||
|
|
||||||
|
const MOCK_SUMMARY3: Summary = {
|
||||||
|
pointsBalance: 15420,
|
||||||
|
offers: [
|
||||||
|
{ id: "ofr_50", title: "ส่วนลด 50 บาท", pointsCost: 800, short: "ขั้นต่ำ 300 บาท", available: true },
|
||||||
|
{ id: "ofr_100", title: "ส่วนลด 100 บาท", pointsCost: 1500, short: "ขั้นต่ำ 600 บาท", available: true },
|
||||||
|
{ id: "ofr_coffee", title: "คูปองกาแฟฟรี 1 แก้ว", pointsCost: 1200, available: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getSummary(): Promise<ApiResult<Summary>> {
|
||||||
|
if (USE_MOCK3) {
|
||||||
|
await sleep3(MOCK_DELAY_MS3);
|
||||||
|
return { ok: true, data: MOCK_SUMMARY3 } as const;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/member/summary", { cache: "no-store" });
|
||||||
|
const data = (await res.json()) as Summary;
|
||||||
|
if (!res.ok) return { ok: false, status: res.status, message: (data as any)?.message ?? res.statusText };
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, status: 0, message: e?.message ?? "Network error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postExchange(offerId: string, idemKey: string): Promise<ApiResult<ExchangeResponse>> {
|
||||||
|
if (USE_MOCK3) {
|
||||||
|
await sleep3(MOCK_DELAY_MS3);
|
||||||
|
const code = `AF-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
|
||||||
|
return { ok: true, data: { balance: MOCK_SUMMARY3.pointsBalance - (MOCK_SUMMARY3.offers.find(o => o.id === offerId)?.pointsCost ?? 0), voucherId: `vch_${offerId}`, voucherCode: code } } as const;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/member/exchange", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-Idempotency-Key": idemKey },
|
||||||
|
body: JSON.stringify({ offerId }),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as ExchangeResponse;
|
||||||
|
if (!res.ok) return { ok: false, status: res.status, message: (data as any)?.message ?? res.statusText };
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, status: 0, message: e?.message ?? "Network error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExchangePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [summary, setSummary] = useState<Summary | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const idemRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const r = await getSummary();
|
||||||
|
if (!r.ok) {
|
||||||
|
if (r.status === 401) router.replace(`/member/login?redirect=${encodeURIComponent("/member/exchange")}`);
|
||||||
|
else setError(r.message);
|
||||||
|
} else {
|
||||||
|
setSummary(r.data);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const canSubmit = !!selected && !loading;
|
||||||
|
|
||||||
|
async function onExchange() {
|
||||||
|
if (!selected) return;
|
||||||
|
setError(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
setLoading(true);
|
||||||
|
idemRef.current = genIdempotencyKey();
|
||||||
|
const r = await postExchange(selected, idemRef.current);
|
||||||
|
setLoading(false);
|
||||||
|
idemRef.current = null;
|
||||||
|
if (!r.ok) {
|
||||||
|
setError(r.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSuccessMsg(`แลกสำเร็จ! รหัสคูปอง: ${r.data.voucherCode}`);
|
||||||
|
setSummary((prev) => (prev ? { ...prev, pointsBalance: r.data.balance } : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<section className="mx-auto flex min-h-screen max-w-3xl flex-col px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight">แลกแต้มเป็นคูปอง/วอชเชอร์</h1>
|
||||||
|
<p className="text-xs text-neutral-500">เลือกข้อเสนอด้านล่างเพื่อแลกทันที</p>
|
||||||
|
|
||||||
|
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||||
|
{successMsg && <div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">{successMsg}</div>}
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-xs text-neutral-500">คะแนนคงเหลือ</div>
|
||||||
|
<div className="mt-1 text-3xl font-extrabold tabular-nums">{loading ? "—" : summary?.pointsBalance?.toLocaleString("th-TH") ?? "0"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{(summary?.offers ?? []).map((o) => (
|
||||||
|
<label key={o.id} className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 shadow-sm ${selected === o.id ? "border-neutral-900" : "border-neutral-200"}`}>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{o.title}</div>
|
||||||
|
<div className="text-xs text-neutral-500">ต้องการ {o.pointsCost.toLocaleString("th-TH")} แต้ม</div>
|
||||||
|
{o.short && <div className="mt-1 text-xs text-neutral-600">{o.short}</div>}
|
||||||
|
</div>
|
||||||
|
<input type="radio" name="offer" checked={selected === o.id} onChange={() => setSelected(o.id)} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<button onClick={onExchange} disabled={!canSubmit} className="rounded-full bg-neutral-900 px-6 py-3 text-sm font-semibold text-white hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50">
|
||||||
|
{loading ? "กำลังแลก..." : "ยืนยันการแลก"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
118
src/app/(public)/member/login/page.tsx
Normal file
118
src/app/(public)/member/login/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// =============================
|
||||||
|
// File: src/app/(public)/member/login/page.tsx
|
||||||
|
// =============================
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import BrandLogo from "@/app/component/common/BrandLogo";
|
||||||
|
|
||||||
|
// Mock switch
|
||||||
|
const USE_MOCK = (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_USE_MOCK === "1") || true;
|
||||||
|
const MOCK_DELAY_MS = 400;
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
type LoginResponse = { ok: true } | { ok: false; message: string };
|
||||||
|
|
||||||
|
async function apiLogin(phone: string, pin: string): Promise<LoginResponse> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await sleep(MOCK_DELAY_MS);
|
||||||
|
if (pin === "1234") return { ok: true } as const;
|
||||||
|
return { ok: false, message: "เบอร์หรือรหัสผ่านไม่ถูกต้อง" } as const;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/member/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone, pin }),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (res.ok) return { ok: true } as const;
|
||||||
|
const txt = await res.text();
|
||||||
|
return { ok: false, message: txt || res.statusText } as const;
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, message: e?.message ?? "Network error" } as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const search = useSearchParams();
|
||||||
|
const redirect = search?.get("redirect") || "/member";
|
||||||
|
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const phoneValid = useMemo(() => /^\d{10}$/.test(phone), [phone]);
|
||||||
|
const pinValid = useMemo(() => pin.length >= 4, [pin]);
|
||||||
|
const canSubmit = phoneValid && pinValid && !loading;
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await apiLogin(phone, pin);
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(res.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<section className="mx-auto flex min-h-screen max-w-sm flex-col items-center justify-center px-4">
|
||||||
|
<BrandLogo />
|
||||||
|
<h1 className="mt-5 text-2xl font-extrabold">เข้าสู่ระบบสมาชิก</h1>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">ไม่มี OTP • ใช้เบอร์ + PIN/รหัสผ่าน</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 w-full space-y-4 rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-800">เบอร์โทรศัพท์</label>
|
||||||
|
<input
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 10))}
|
||||||
|
className="mt-1 h-11 w-full rounded-lg border border-neutral-300 px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 focus:ring-neutral-200"
|
||||||
|
placeholder="เช่น 0812345678"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="tel"
|
||||||
|
maxLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{!phoneValid && phone.length > 0 && (
|
||||||
|
<div className="mt-1 text-xs text-red-600">กรอกเป็นตัวเลข 10 หลัก</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-800">PIN / รหัสผ่าน</label>
|
||||||
|
<input
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) => setPin(e.target.value)}
|
||||||
|
className="mt-1 h-11 w-full rounded-lg border border-neutral-300 px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 focus:ring-neutral-200"
|
||||||
|
type="password"
|
||||||
|
placeholder="เช่น 1234"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{!pinValid && pin.length > 0 && (
|
||||||
|
<div className="mt-1 text-xs text-red-600">อย่างน้อย 4 ตัวอักษร</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-2 text-xs text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="w-full rounded-full bg-neutral-900 py-3 text-sm font-semibold text-white hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "กำลังเข้าสู่ระบบ..." : "เข้าสู่ระบบ"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/(public)/member/logout/page.tsx
Normal file
19
src/app/(public)/member/logout/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function LogoutPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try { await fetch("/api/member/logout", { method: "POST" }); } catch {}
|
||||||
|
router.replace("/member/login");
|
||||||
|
})();
|
||||||
|
}, [router]);
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-screen place-items-center bg-white text-neutral-600">
|
||||||
|
<div className="text-sm">กำลังออกจากระบบ…</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
428
src/app/(public)/member/page.tsx
Normal file
428
src/app/(public)/member/page.tsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
// File: src/app/(public)/member/page.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useEffect, useMemo, useRef, useState} from "react";
|
||||||
|
import NextLink from "next/link";
|
||||||
|
import {useRouter, useSearchParams} from "next/navigation";
|
||||||
|
import BrandLogo from "@/app/component/common/BrandLogo";
|
||||||
|
|
||||||
|
/* ====================== Types ====================== */
|
||||||
|
type PendingTxn = {
|
||||||
|
txnId: string;
|
||||||
|
type: "add" | "exchange";
|
||||||
|
points?: number;
|
||||||
|
createdAt: string; // ISO
|
||||||
|
status: "pending" | "failed" | "expired";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Offer = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
pointsCost: number;
|
||||||
|
short?: string;
|
||||||
|
available?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VoucherMini = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: "active" | "used" | "expired";
|
||||||
|
expireAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemberSummaryResponse = {
|
||||||
|
memberId: string;
|
||||||
|
name?: string;
|
||||||
|
phoneLast4?: string;
|
||||||
|
pointsBalance: number;
|
||||||
|
vouchers: { active: number; used: number; expired: number; latest?: VoucherMini[] };
|
||||||
|
pendingTransactions: PendingTxn[];
|
||||||
|
offers?: Offer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; status: number; message: string };
|
||||||
|
|
||||||
|
/* ====================== MOCK DATA ====================== */
|
||||||
|
// เปิด/ปิดการใช้ Mock ได้จาก env หรือบังคับ true ไว้ก่อน
|
||||||
|
const USE_MOCK =
|
||||||
|
(typeof process !== "undefined" && process.env?.NEXT_PUBLIC_USE_MOCK === "1") || true;
|
||||||
|
|
||||||
|
const MOCK_DELAY_MS = 500;
|
||||||
|
|
||||||
|
const MOCK_SUMMARY: MemberSummaryResponse = {
|
||||||
|
memberId: "mem_tenantA_0001",
|
||||||
|
name: "คุณธนาคาร",
|
||||||
|
phoneLast4: "1728",
|
||||||
|
pointsBalance: 15420,
|
||||||
|
vouchers: {
|
||||||
|
active: 2,
|
||||||
|
used: 9,
|
||||||
|
expired: 1,
|
||||||
|
latest: [
|
||||||
|
{
|
||||||
|
id: "vch_9x27af",
|
||||||
|
code: "AF-TH-9X27",
|
||||||
|
status: "active",
|
||||||
|
expireAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 15).toISOString(), // +15 วัน
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vch_zp31qt",
|
||||||
|
code: "AF-TH-ZP31",
|
||||||
|
status: "active",
|
||||||
|
expireAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 3).toISOString(), // +3 วัน
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pendingTransactions: [
|
||||||
|
{
|
||||||
|
txnId: "TXN-2025-001234",
|
||||||
|
type: "add",
|
||||||
|
points: 100,
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 นาทีที่แล้ว
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
txnId: "TXN-2025-001235",
|
||||||
|
type: "exchange",
|
||||||
|
points: -500,
|
||||||
|
createdAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), // 1.5 ชม.ที่แล้ว
|
||||||
|
status: "failed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
offers: [
|
||||||
|
{ id: "ofr_coffee", title: "คูปองกาแฟฟรี 1 แก้ว", pointsCost: 1200, short: "ใช้ได้ทุกสาขา", available: true },
|
||||||
|
{ id: "ofr_50", title: "ส่วนลด 50 บาท", pointsCost: 800, short: "ขั้นต่ำ 300 บาท", available: true },
|
||||||
|
{ id: "ofr_waiver", title: "ค่าจัดส่งฟรี", pointsCost: 400, short: "ในเขตให้บริการ", available: true },
|
||||||
|
{ id: "ofr_spa", title: "วอชเชอร์สปา 30 นาที", pointsCost: 5000, short: "จำกัด 1 สิทธิ์/เดือน", available: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetch<T>(url: string): Promise<ApiResult<T>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (url.startsWith("/api/member/summary")) {
|
||||||
|
resolve({ ok: true, data: MOCK_SUMMARY as unknown as T });
|
||||||
|
} else {
|
||||||
|
resolve({ ok: false, status: 404, message: "Mock: endpoint not implemented" });
|
||||||
|
}
|
||||||
|
}, MOCK_DELAY_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== Helpers ====================== */
|
||||||
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<ApiResult<T>> {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
return mockFetch<T>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: "no-store", ...init });
|
||||||
|
if (res.status === 204) return { ok: true, data: undefined as unknown as T };
|
||||||
|
const txt = await res.text();
|
||||||
|
const data = txt ? (JSON.parse(txt) as T) : (undefined as unknown as T);
|
||||||
|
if (!res.ok) return { ok: false, status: res.status, message: (data as any)?.message ?? res.statusText };
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, status: 0, message: e?.message ?? "Network error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(dt?: string) {
|
||||||
|
if (!dt) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(dt);
|
||||||
|
return d.toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
} catch {
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== Page ====================== */
|
||||||
|
export default function MemberHubPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const search = useSearchParams();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [summary, setSummary] = useState<MemberSummaryResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// quick input for transaction (กรณีลูกค้ามีเลขจากหน้าคิดเงิน)
|
||||||
|
const [txnInput, setTxnInput] = useState(search?.get("txn") ?? "");
|
||||||
|
|
||||||
|
// refresh lock
|
||||||
|
const refreshing = useRef(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (refreshing.current) return;
|
||||||
|
refreshing.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetchJson<MemberSummaryResponse>("/api/member/summary");
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.replace(`/member/login?redirect=${encodeURIComponent("/member")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(res.message || "โหลดข้อมูลไม่สำเร็จ");
|
||||||
|
setLoading(false);
|
||||||
|
refreshing.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSummary(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
refreshing.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const vouchersLatest = useMemo(() => summary?.vouchers.latest ?? [], [summary]);
|
||||||
|
const canGoRedeem = useMemo(() => !!txnInput && txnInput.trim().length > 0, [txnInput]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<section className="mx-auto flex min-h-screen max-w-4xl flex-col px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BrandLogo />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight">ศูนย์สมาชิก</h1>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
ยินดีต้อนรับ{summary?.name ? `, ${summary.name}` : ""}{" "}
|
||||||
|
{summary?.phoneLast4 ? `(ลงท้าย ${summary.phoneLast4})` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="rounded-full border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-50 active:scale-[.99]"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
รีเฟรช
|
||||||
|
</button>
|
||||||
|
<NextLink
|
||||||
|
href="/member/logout"
|
||||||
|
className="rounded-full border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
ออกจากระบบ
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview cards */}
|
||||||
|
<section className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-xs text-neutral-500">คะแนนคงเหลือ</div>
|
||||||
|
<div className="mt-1 text-3xl font-extrabold tabular-nums">
|
||||||
|
{loading ? "—" : summary?.pointsBalance?.toLocaleString("th-TH") ?? "0"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<NextLink
|
||||||
|
href="/member/exchange"
|
||||||
|
className="text-sm font-semibold text-neutral-900 underline underline-offset-4 hover:opacity-80"
|
||||||
|
>
|
||||||
|
แลกคูปอง/วอชเชอร์ →
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-xs text-neutral-500">คูปองของฉัน (ใช้งานได้)</div>
|
||||||
|
<div className="mt-1 text-3xl font-extrabold tabular-nums">
|
||||||
|
{loading ? "—" : summary?.vouchers?.active ?? 0}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-sm">
|
||||||
|
<NextLink
|
||||||
|
href="/member/vouchers"
|
||||||
|
className="font-semibold underline underline-offset-4 hover:opacity-80"
|
||||||
|
>
|
||||||
|
ดูทั้งหมด →
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-xs text-neutral-500">ธุรกรรมค้าง</div>
|
||||||
|
<div className="mt-1 text-3xl font-extrabold tabular-nums">
|
||||||
|
{loading ? "—" : (summary?.pendingTransactions?.length ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<NextLink
|
||||||
|
href="#pending"
|
||||||
|
className="text-sm font-semibold underline underline-offset-4 hover:opacity-80"
|
||||||
|
>
|
||||||
|
เลื่อนไปดูรายการค้าง ↓
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<section className="mt-8 rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-neutral-800">หมายเลขธุรกรรม (ถ้ามี)</label>
|
||||||
|
<input
|
||||||
|
value={txnInput}
|
||||||
|
onChange={(e) => setTxnInput(e.target.value)}
|
||||||
|
placeholder="เช่น TXN-2025-000123"
|
||||||
|
className="mt-1 h-11 w-full rounded-lg border border-neutral-300 px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 focus:ring-neutral-200"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
ถ้าทำลิงก์หาย ให้กรอกหมายเลขธุรกรรมที่ได้รับจากหน้าคิดเงิน
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NextLink
|
||||||
|
href={canGoRedeem ? `/member/redeem?txn=${encodeURIComponent(txnInput.trim())}` : "/member/redeem"}
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-neutral-900 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800 active:scale-[.99]"
|
||||||
|
>
|
||||||
|
ไปสะสมคะแนน
|
||||||
|
</NextLink>
|
||||||
|
<NextLink
|
||||||
|
href="/member/exchange"
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-neutral-300 px-5 py-3 text-sm font-semibold hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
แลกแต้มเป็นคูปอง
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Offers */}
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-lg font-bold">ข้อเสนอที่แลกได้</h2>
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{(summary?.offers ?? []).length === 0 && !loading && (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-700">
|
||||||
|
ยังไม่มีข้อเสนอแนะนำในขณะนี้
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(summary?.offers ?? []).map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.id}
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{o.title}</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
ต้องการ {o.pointsCost.toLocaleString("th-TH")} แต้ม
|
||||||
|
</div>
|
||||||
|
{o.short && <div className="mt-1 text-xs text-neutral-600">{o.short}</div>}
|
||||||
|
</div>
|
||||||
|
<NextLink
|
||||||
|
href="/member/exchange"
|
||||||
|
className="rounded-full border border-neutral-300 px-4 py-2 text-sm font-semibold hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
แลกเลย
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Latest vouchers */}
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-lg font-bold">คูปองล่าสุดของฉัน</h2>
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{(!vouchersLatest || vouchersLatest.length === 0) && !loading && (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-700">
|
||||||
|
ยังไม่มีคูปองล่าสุด
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vouchersLatest?.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
รหัส: <span className="font-mono">{v.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
สถานะ: {v.status === "active" ? "ใช้งานได้" : v.status === "used" ? "ใช้แล้ว" : "หมดอายุ"}
|
||||||
|
{v.expireAt ? ` • หมดอายุ ${fmtDate(v.expireAt)}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NextLink
|
||||||
|
href={`/member/voucher/${encodeURIComponent(v.id)}`}
|
||||||
|
className="rounded-full border border-neutral-300 px-4 py-2 text-sm font-semibold hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
เปิดแสดง
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pending transactions */}
|
||||||
|
<section id="pending" className="mt-8">
|
||||||
|
<h2 className="text-lg font-bold">ธุรกรรมค้างของฉัน</h2>
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-3">
|
||||||
|
{(summary?.pendingTransactions ?? []).length === 0 && !loading && (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-700">
|
||||||
|
ไม่มีธุรกรรมค้าง
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(summary?.pendingTransactions ?? []).map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.txnId}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{p.type === "add" ? "เพิ่มแต้ม" : "แลกแต้ม"} •{" "}
|
||||||
|
<span className="font-mono">{p.txnId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
เวลา {fmtDate(p.createdAt)}
|
||||||
|
{typeof p.points === "number"
|
||||||
|
? ` • ${p.points > 0 ? "+" : ""}${p.points.toLocaleString("th-TH")} แต้ม`
|
||||||
|
: ""}
|
||||||
|
{" • สถานะ "}
|
||||||
|
{p.status === "pending" ? "รอดำเนินการ" : p.status === "failed" ? "ล้มเหลว" : "หมดอายุ"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{p.type === "add" ? (
|
||||||
|
<NextLink
|
||||||
|
href={`/member/redeem?txn=${encodeURIComponent(p.txnId)}`}
|
||||||
|
className="rounded-full bg-neutral-900 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-800 active:scale-[.99]"
|
||||||
|
>
|
||||||
|
ดำเนินการต่อ
|
||||||
|
</NextLink>
|
||||||
|
) : (
|
||||||
|
<NextLink
|
||||||
|
href={`/member/exchange`}
|
||||||
|
className="rounded-full bg-neutral-900 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-800 active:scale-[.99]"
|
||||||
|
>
|
||||||
|
ดำเนินการต่อ
|
||||||
|
</NextLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-12 pb-8 text-center text-xs text-neutral-400">
|
||||||
|
© {new Date().getFullYear()} Aetherframe • Member Hub
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useMemo, useRef, useState} from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import {useParams, useSearchParams} from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import BrandLogo from "@/app/component/common/BrandLogo";
|
import BrandLogo from "@/app/component/common/BrandLogo";
|
||||||
import {genIdempotencyKey} from "@/server/middleware/idempotency";
|
import { genIdempotencyKey } from "@/server/middleware/idempotency";
|
||||||
import {resolveTenantKeyFromHost} from "@/types/tenant/tenant";
|
import { resolveTenantKeyFromHost } from "@/types/tenant/tenant";
|
||||||
import {onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode} from "@/modules/phone/utils";
|
import { onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode } from "@/modules/phone/utils";
|
||||||
import type {RedeemResponse, RedeemRequest} from "@/types/crm";
|
import type { RedeemResponse, RedeemRequest } from "@/types/crm";
|
||||||
|
|
||||||
/* ===== Types ===== */
|
|
||||||
type OnboardRequest = {
|
type OnboardRequest = {
|
||||||
contact: { phone: string };
|
contact: { phone: string };
|
||||||
consent?: { termsAccepted: boolean; marketingOptIn?: boolean };
|
consent?: { termsAccepted: boolean; marketingOptIn?: boolean };
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnboardResponse = {
|
type OnboardResponse = {
|
||||||
contactId: string;
|
contactId: string;
|
||||||
loyaltyAccountId: string;
|
loyaltyAccountId: string;
|
||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
pointsBalance?: number;
|
pointsBalance?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatusResponse = {
|
type StatusResponse = {
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
hasLoyalty: boolean;
|
hasLoyalty: boolean;
|
||||||
@@ -28,6 +29,7 @@ type StatusResponse = {
|
|||||||
loyaltyAccountId?: string;
|
loyaltyAccountId?: string;
|
||||||
pointsBalance?: number;
|
pointsBalance?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CombinedResult = {
|
type CombinedResult = {
|
||||||
status?: StatusResponse;
|
status?: StatusResponse;
|
||||||
onboarding?: OnboardResponse;
|
onboarding?: OnboardResponse;
|
||||||
@@ -36,11 +38,19 @@ type CombinedResult = {
|
|||||||
|
|
||||||
type ApiErrorBody = { code?: string; message?: string };
|
type ApiErrorBody = { code?: string; message?: string };
|
||||||
|
|
||||||
/* ===== Page ===== */
|
const USE_MOCK =
|
||||||
|
(typeof process !== "undefined" && process.env?.NEXT_PUBLIC_USE_MOCK === "1") || true; // ตั้ง true เพื่อให้รันทันที
|
||||||
|
const MOCK_DELAY_MS = 400;
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
export default function RedeemPage() {
|
export default function RedeemPage() {
|
||||||
const params = useParams<{ transactionId: string }>();
|
const params = useParams<{ transactionId: string }>();
|
||||||
const search = useSearchParams();
|
const search = useSearchParams();
|
||||||
const transactionId = params?.transactionId ?? search?.get("transactionId") ?? "";
|
const transactionId =
|
||||||
|
(params?.transactionId as string) ??
|
||||||
|
(search?.get("transactionId") as string) ??
|
||||||
|
(search?.get("txn") as string) ??
|
||||||
|
"";
|
||||||
|
|
||||||
const [phoneDigits, setPhoneDigits] = useState("");
|
const [phoneDigits, setPhoneDigits] = useState("");
|
||||||
const [agreeTerms, setAgreeTerms] = useState(false);
|
const [agreeTerms, setAgreeTerms] = useState(false);
|
||||||
@@ -51,16 +61,13 @@ export default function RedeemPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<CombinedResult | null>(null);
|
const [result, setResult] = useState<CombinedResult | null>(null);
|
||||||
|
|
||||||
// UI branching
|
|
||||||
const [showConsent, setShowConsent] = useState(false);
|
const [showConsent, setShowConsent] = useState(false);
|
||||||
const [consentReason, setConsentReason] = useState<string | null>(null); // e.g. "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND"
|
const [consentReason, setConsentReason] = useState<string | null>(null);
|
||||||
|
|
||||||
// idempotency for each call
|
|
||||||
const idemStatusRef = useRef<string | null>(null);
|
const idemStatusRef = useRef<string | null>(null);
|
||||||
const idemOnboardRef = useRef<string | null>(null);
|
const idemOnboardRef = useRef<string | null>(null);
|
||||||
const idemRedeemRef = useRef<string | null>(null);
|
const idemRedeemRef = useRef<string | null>(null);
|
||||||
|
|
||||||
/* ===== Validation ===== */
|
|
||||||
const phoneError = useMemo(() => {
|
const phoneError = useMemo(() => {
|
||||||
if (!phoneDigits) return null;
|
if (!phoneDigits) return null;
|
||||||
if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
|
if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
|
||||||
@@ -73,11 +80,9 @@ export default function RedeemPage() {
|
|||||||
const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits);
|
const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits);
|
||||||
const canSubmit = phoneValid && !loading && (!showConsent || (showConsent && agreeTerms));
|
const canSubmit = phoneValid && !loading && (!showConsent || (showConsent && agreeTerms));
|
||||||
|
|
||||||
/* ===== Input guards ===== */
|
|
||||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
|
setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
|
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
|
||||||
const nativeEvt = e.nativeEvent as unknown;
|
const nativeEvt = e.nativeEvent as unknown;
|
||||||
const data =
|
const data =
|
||||||
@@ -104,27 +109,80 @@ export default function RedeemPage() {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
json?: any;
|
json?: any;
|
||||||
text?: string;
|
text?: string;
|
||||||
err?: ApiErrorBody
|
err?: ApiErrorBody;
|
||||||
}> {
|
}> {
|
||||||
const txt = await res.text();
|
const txt = await res.text();
|
||||||
try {
|
try {
|
||||||
const json = txt ? JSON.parse(txt) : undefined;
|
const json = txt ? JSON.parse(txt) : undefined;
|
||||||
if (res.ok) return {ok: true, json};
|
if (res.ok) return { ok: true, json };
|
||||||
return {ok: false, err: json ?? {message: txt}};
|
return { ok: false, err: json ?? { message: txt } };
|
||||||
} catch {
|
} catch {
|
||||||
if (res.ok) return {ok: true, text: txt};
|
if (res.ok) return { ok: true, text: txt };
|
||||||
return {ok: false, err: {message: txt}};
|
return { ok: false, err: { message: txt } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mockGetStatus(): Promise<StatusResponse> {
|
||||||
|
await sleep(MOCK_DELAY_MS);
|
||||||
|
const last2 = phoneDigits.slice(-2);
|
||||||
|
if (last2 === "00") {
|
||||||
|
return { exists: false, hasLoyalty: false, consentRequired: true };
|
||||||
|
}
|
||||||
|
if (last2 === "11") {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
hasLoyalty: true,
|
||||||
|
consentRequired: true,
|
||||||
|
contactId: `ct_${phoneDigits}`,
|
||||||
|
loyaltyAccountId: `loy_${phoneDigits}`,
|
||||||
|
pointsBalance: 4200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
hasLoyalty: true,
|
||||||
|
consentRequired: false,
|
||||||
|
contactId: `ct_${phoneDigits}`,
|
||||||
|
loyaltyAccountId: `loy_${phoneDigits}`,
|
||||||
|
pointsBalance: 5120,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockOnboard(): Promise<OnboardResponse> {
|
||||||
|
await sleep(MOCK_DELAY_MS);
|
||||||
|
const isNew = phoneDigits.slice(-1) % 2 === 1; // เลขท้ายคี่ = สมัครใหม่
|
||||||
|
return {
|
||||||
|
contactId: `ct_${phoneDigits}`,
|
||||||
|
loyaltyAccountId: `loy_${phoneDigits}`,
|
||||||
|
isNew,
|
||||||
|
pointsBalance: isNew ? 0 : 5120,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockRedeem(): Promise<RedeemResponse> {
|
||||||
|
await sleep(MOCK_DELAY_MS);
|
||||||
|
const base = 5120;
|
||||||
|
const delta = transactionId ? 100 : 0;
|
||||||
|
const balance = base + delta;
|
||||||
|
const hasVoucher = (transactionId || "").length % 2 === 0;
|
||||||
|
return {
|
||||||
|
balance,
|
||||||
|
ledgerEntryId: `led_${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
redeemed: true,
|
||||||
|
...(hasVoucher ? { voucherCode: `AF-${Math.random().toString(36).slice(2, 6).toUpperCase()}` } : {}),
|
||||||
|
} as unknown as RedeemResponse;
|
||||||
|
}
|
||||||
|
|
||||||
async function getStatus(tenantKey: string): Promise<StatusResponse> {
|
async function getStatus(tenantKey: string): Promise<StatusResponse> {
|
||||||
|
if (USE_MOCK) return mockGetStatus();
|
||||||
|
|
||||||
idemStatusRef.current = genIdempotencyKey();
|
idemStatusRef.current = genIdempotencyKey();
|
||||||
const url = `/api/public/loyalty/status?phone=${encodeURIComponent(phoneDigits)}`;
|
const url = `/api/public/loyalty/status?phone=${encodeURIComponent(phoneDigits)}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Tenant-Key": tenantKey,
|
"X-Tenant-Key": tenantKey, // API ฝั่ง server ควร resolve จาก subdomain เป็นหลัก ส่วน header นี้เป็น optional
|
||||||
"Idempotency-Key": idemStatusRef.current!,
|
"X-Idempotency-Key": idemStatusRef.current!, // เปลี่ยนชื่อ header ให้สอดคล้อง
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@@ -137,10 +195,12 @@ export default function RedeemPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onboard(tenantKey: string): Promise<OnboardResponse> {
|
async function onboard(tenantKey: string): Promise<OnboardResponse> {
|
||||||
|
if (USE_MOCK) return mockOnboard();
|
||||||
|
|
||||||
idemOnboardRef.current = genIdempotencyKey();
|
idemOnboardRef.current = genIdempotencyKey();
|
||||||
const payload: OnboardRequest = {
|
const payload: OnboardRequest = {
|
||||||
contact: {phone: phoneDigits},
|
contact: { phone: phoneDigits },
|
||||||
consent: agreeTerms ? {termsAccepted: true, marketingOptIn} : undefined,
|
consent: agreeTerms ? { termsAccepted: true, marketingOptIn } : undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: "qr-landing",
|
source: "qr-landing",
|
||||||
consentAt: agreeTerms ? new Date().toISOString() : undefined,
|
consentAt: agreeTerms ? new Date().toISOString() : undefined,
|
||||||
@@ -151,7 +211,7 @@ export default function RedeemPage() {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Tenant-Key": tenantKey,
|
"X-Tenant-Key": tenantKey,
|
||||||
"Idempotency-Key": idemOnboardRef.current!,
|
"X-Idempotency-Key": idemOnboardRef.current!,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -165,18 +225,20 @@ export default function RedeemPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function redeem(tenantKey: string): Promise<RedeemResponse> {
|
async function redeem(tenantKey: string): Promise<RedeemResponse> {
|
||||||
|
if (USE_MOCK) return mockRedeem();
|
||||||
|
|
||||||
idemRedeemRef.current = genIdempotencyKey();
|
idemRedeemRef.current = genIdempotencyKey();
|
||||||
const payload: RedeemRequest = {
|
const payload: RedeemRequest = {
|
||||||
transactionId,
|
transactionId,
|
||||||
contact: {phone: phoneDigits},
|
contact: { phone: phoneDigits },
|
||||||
metadata: {source: "qr-landing"},
|
metadata: { source: "qr-landing" },
|
||||||
};
|
};
|
||||||
const res = await fetch(`/api/public/redeem`, {
|
const res = await fetch(`/api/public/redeem`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Tenant-Key": tenantKey,
|
"X-Tenant-Key": tenantKey,
|
||||||
"Idempotency-Key": idemRedeemRef.current!,
|
"X-Idempotency-Key": idemRedeemRef.current!,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -206,20 +268,19 @@ export default function RedeemPage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
const tenantKey = resolveTenantKeyFromHost();
|
const tenantKey = resolveTenantKeyFromHost(); // backend จะอ่านจาก subdomain เป็นหลักอยู่แล้ว
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Case A: มี transaction → พยายาม Redeem ก่อน แล้วค่อยถาม consent ถ้าจำเป็น
|
// Case A: มี transaction → พยายาม Redeem ก่อน แล้วค่อยถาม consent ถ้าจำเป็น
|
||||||
if (transactionId) {
|
if (transactionId) {
|
||||||
try {
|
try {
|
||||||
const redeemRes = await redeem(tenantKey);
|
const redeemRes = await redeem(tenantKey);
|
||||||
setResult({redeem: redeemRes});
|
setResult({ redeem: redeemRes });
|
||||||
setMessage("สะสมคะแนนสำเร็จ");
|
setMessage("สะสมคะแนนสำเร็จ");
|
||||||
setShowConsent(false);
|
setShowConsent(false);
|
||||||
setConsentReason(null);
|
setConsentReason(null);
|
||||||
return;
|
return;
|
||||||
} catch (exFirst: any) {
|
} catch (exFirst: any) {
|
||||||
// ถ้าล้มเหลวเพราะต้องยินยอมหรือไม่มีบัญชี โชว์ consent แล้วรอให้ผู้ใช้ติ๊ก จากนั้น onboard และ redeem ซ้ำ
|
|
||||||
if (!showConsent) {
|
if (!showConsent) {
|
||||||
// โชว์ consent แล้วให้ผู้ใช้กดปุ่มเดิมอีกครั้งหลังติ๊ก
|
// โชว์ consent แล้วให้ผู้ใช้กดปุ่มเดิมอีกครั้งหลังติ๊ก
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -228,7 +289,7 @@ export default function RedeemPage() {
|
|||||||
// ผู้ใช้ติ๊กแล้ว → สร้าง/อัปเดต consent แล้ว Redeem ซ้ำ
|
// ผู้ใช้ติ๊กแล้ว → สร้าง/อัปเดต consent แล้ว Redeem ซ้ำ
|
||||||
const onboarding = await onboard(tenantKey);
|
const onboarding = await onboard(tenantKey);
|
||||||
const redeemRes = await redeem(tenantKey);
|
const redeemRes = await redeem(tenantKey);
|
||||||
setResult({onboarding, redeem: redeemRes});
|
setResult({ onboarding, redeem: redeemRes });
|
||||||
setMessage(onboarding.isNew ? "สมัครสมาชิกและสะสมคะแนนสำเร็จ" : "ยืนยันสมาชิกและสะสมคะแนนสำเร็จ");
|
setMessage(onboarding.isNew ? "สมัครสมาชิกและสะสมคะแนนสำเร็จ" : "ยืนยันสมาชิกและสะสมคะแนนสำเร็จ");
|
||||||
setShowConsent(false);
|
setShowConsent(false);
|
||||||
setConsentReason(null);
|
setConsentReason(null);
|
||||||
@@ -238,7 +299,7 @@ export default function RedeemPage() {
|
|||||||
|
|
||||||
// Case B: ไม่มี transaction → ตรวจสถานะก่อน (มีบัญชี/ยินยอมแล้วหรือยัง)
|
// Case B: ไม่มี transaction → ตรวจสถานะก่อน (มีบัญชี/ยินยอมแล้วหรือยัง)
|
||||||
const status = await getStatus(tenantKey);
|
const status = await getStatus(tenantKey);
|
||||||
setResult({status});
|
setResult({ status });
|
||||||
|
|
||||||
if (!status.exists || !status.hasLoyalty) {
|
if (!status.exists || !status.hasLoyalty) {
|
||||||
// ยังไม่มีบัญชี → ต้อง onboard (ต้องขอ consent)
|
// ยังไม่มีบัญชี → ต้อง onboard (ต้องขอ consent)
|
||||||
@@ -249,7 +310,7 @@ export default function RedeemPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const onboarding = await onboard(tenantKey);
|
const onboarding = await onboard(tenantKey);
|
||||||
setResult({status, onboarding});
|
setResult({ status, onboarding });
|
||||||
setMessage(onboarding.isNew ? "สมัครสมาชิกสำเร็จ" : "ยืนยันสมาชิกสำเร็จ");
|
setMessage(onboarding.isNew ? "สมัครสมาชิกสำเร็จ" : "ยืนยันสมาชิกสำเร็จ");
|
||||||
setShowConsent(false);
|
setShowConsent(false);
|
||||||
setConsentReason(null);
|
setConsentReason(null);
|
||||||
@@ -265,14 +326,14 @@ export default function RedeemPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const onboarding = await onboard(tenantKey);
|
const onboarding = await onboard(tenantKey);
|
||||||
setResult({status, onboarding});
|
setResult({ status, onboarding });
|
||||||
setMessage("อัปเดตการยินยอมสำเร็จ");
|
setMessage("อัปเดตการยินยอมสำเร็จ");
|
||||||
setShowConsent(false);
|
setShowConsent(false);
|
||||||
setConsentReason(null);
|
setConsentReason(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลลัพธ์สั้นๆ
|
// มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลสั้น ๆ
|
||||||
setMessage("เข้าสู่ระบบสมาชิกสำเร็จ");
|
setMessage("เข้าสู่ระบบสมาชิกสำเร็จ");
|
||||||
setShowConsent(false);
|
setShowConsent(false);
|
||||||
setConsentReason(null);
|
setConsentReason(null);
|
||||||
@@ -292,10 +353,10 @@ export default function RedeemPage() {
|
|||||||
<main className="min-h-screen bg-white text-neutral-900">
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
<section className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center px-4">
|
<section className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center px-4">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<BrandLogo/>
|
<BrandLogo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="mt-5 text-3xl font-extrabold tracking-tight">Redeem / Join Loyalty</h1>
|
<h1 className="mt-5 text-3xl font-extrabold tracking-tight">Redeem / Join</h1>
|
||||||
<p className="mt-1 text-xs text-neutral-500">
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
Transaction: <span className="font-mono">{transactionId || "—"}</span>
|
Transaction: <span className="font-mono">{transactionId || "—"}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -343,9 +404,7 @@ export default function RedeemPage() {
|
|||||||
{showConsent && (
|
{showConsent && (
|
||||||
<div className="space-y-3 rounded-xl bg-neutral-50 p-4">
|
<div className="space-y-3 rounded-xl bg-neutral-50 p-4">
|
||||||
<div className="text-sm font-medium text-neutral-800">
|
<div className="text-sm font-medium text-neutral-800">
|
||||||
{consentReason === "CONTACT_NOT_FOUND"
|
{consentReason === "CONTACT_NOT_FOUND" ? "สมัครสมาชิกและยินยอมตามข้อตกลง" : "อัปเดตการยินยอมตามข้อตกลง"}
|
||||||
? "สมัครสมาชิกและยินยอมตามข้อตกลง"
|
|
||||||
: "อัปเดตการยินยอมตามข้อตกลง"}
|
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-start gap-3">
|
<label className="flex items-start gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -365,13 +424,9 @@ export default function RedeemPage() {
|
|||||||
checked={marketingOptIn}
|
checked={marketingOptIn}
|
||||||
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-neutral-700">
|
<span className="text-sm text-neutral-700">ยินยอมรับข่าวสาร/ข้อเสนอพิเศษผ่านช่องทางที่เหมาะสม</span>
|
||||||
ยินยอมรับข่าวสาร/ข้อเสนอพิเศษผ่านช่องทางที่เหมาะสม
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
{!agreeTerms && (
|
{!agreeTerms && <div className="text-xs text-red-600">โปรดยอมรับข้อตกลงก่อนดำเนินการต่อ</div>}
|
||||||
<div className="text-xs text-red-600">โปรดยอมรับข้อตกลงก่อนดำเนินการต่อ</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -397,15 +452,12 @@ export default function RedeemPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
|
||||||
className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
|
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
@@ -419,8 +471,7 @@ export default function RedeemPage() {
|
|||||||
<div>ต้องยินยอม: {String(result.status.consentRequired)}</div>
|
<div>ต้องยินยอม: {String(result.status.consentRequired)}</div>
|
||||||
{typeof result.status.pointsBalance === "number" && (
|
{typeof result.status.pointsBalance === "number" && (
|
||||||
<div>
|
<div>
|
||||||
คะแนนคงเหลือ: <span
|
คะแนนคงเหลือ: <span className="font-semibold">{result.status.pointsBalance}</span>
|
||||||
className="font-semibold">{result.status.pointsBalance}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -435,15 +486,14 @@ export default function RedeemPage() {
|
|||||||
Contact: <span className="font-mono">{result.onboarding.contactId}</span>
|
Contact: <span className="font-mono">{result.onboarding.contactId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Loyalty: <span
|
Loyalty: <span className="font-mono">{result.onboarding.loyaltyAccountId}</span>
|
||||||
className="font-mono">{result.onboarding.loyaltyAccountId}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{"pointsBalance" in result.onboarding && typeof result.onboarding.pointsBalance === "number" && (
|
{"pointsBalance" in result.onboarding &&
|
||||||
<div>
|
typeof result.onboarding.pointsBalance === "number" && (
|
||||||
คะแนนคงเหลือ: <span
|
<div>
|
||||||
className="font-semibold">{result.onboarding.pointsBalance}</span>
|
คะแนนคงเหลือ: <span className="font-semibold">{result.onboarding.pointsBalance}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>สถานะ: {result.onboarding.isNew ? "สร้างบัญชีใหม่" : "ยืนยันสมาชิกเดิม"}</div>
|
<div>สถานะ: {result.onboarding.isNew ? "สร้างบัญชีใหม่" : "ยืนยันสมาชิกเดิม"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -455,23 +505,20 @@ export default function RedeemPage() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{"balance" in result.redeem && (
|
{"balance" in result.redeem && (
|
||||||
<div>
|
<div>
|
||||||
ยอดคงเหลือใหม่: <span
|
ยอดคงเหลือใหม่: <span className="font-semibold">{(result.redeem as any).balance}</span>
|
||||||
className="font-semibold">{result.redeem.balance}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{"voucherCode" in result.redeem && (
|
{"voucherCode" in result.redeem && (
|
||||||
<div>
|
<div>
|
||||||
รหัสคูปอง: <span
|
รหัสคูปอง: <span className="font-mono">{(result.redeem as any).voucherCode}</span>
|
||||||
className="font-mono">{result.redeem.voucherCode}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{"ledgerEntryId" in result.redeem && (
|
{"ledgerEntryId" in result.redeem && (
|
||||||
<div>
|
<div>
|
||||||
Ledger: <span className="font-mono">{result.redeem.ledgerEntryId}</span>
|
Ledger: <span className="font-mono">{(result.redeem as any).ledgerEntryId}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{"redeemed" in result.redeem &&
|
{"redeemed" in result.redeem && <div>สถานะ: {String((result.redeem as any).redeemed)}</div>}
|
||||||
<div>สถานะ: {String(result.redeem.redeemed)}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
90
src/app/(public)/member/voucher/[id]/page.tsx
Normal file
90
src/app/(public)/member/voucher/[id]/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
const USE_MOCK5 = (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_USE_MOCK === "1") || true;
|
||||||
|
const MOCK_DELAY_MS5 = 300;
|
||||||
|
const sleep5 = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
type VoucherDetail = { id: string; code: string; status: "active" | "used" | "expired"; expireAt?: string; title?: string; terms?: string };
|
||||||
|
|
||||||
|
type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
|
||||||
|
|
||||||
|
async function getDetail(id: string): Promise<ApiResult<VoucherDetail>> {
|
||||||
|
if (USE_MOCK5) {
|
||||||
|
await sleep5(MOCK_DELAY_MS5);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: id.includes("9x27") ? "AF-TH-9X27" : `AF-${id.slice(-4).toUpperCase()}`,
|
||||||
|
status: "active",
|
||||||
|
title: "คูปองส่วนลด 50 บาท",
|
||||||
|
expireAt: new Date(Date.now() + 5 * 864e5).toISOString(),
|
||||||
|
terms: "ใช้ได้ 1 ครั้ง/บิล • ไม่ร่วมรายการโปรโมชันอื่น • แสดงรหัสก่อนชำระเงิน",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/member/voucher/${encodeURIComponent(id)}`, { cache: "no-store" });
|
||||||
|
const data = (await res.json()) as VoucherDetail;
|
||||||
|
if (!res.ok) return { ok: false, status: res.status, message: (data as any)?.message ?? res.statusText };
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, status: 0, message: e?.message ?? "Network error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoucherPresentPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const id = (params?.id as string) || "";
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [voucher, setVoucher] = useState<VoucherDetail | null>(null);
|
||||||
|
|
||||||
|
// (ออปชัน) แสดง QR ด้วย dynamic import (ถ้ามีไลบรารี)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const r = await getDetail(id);
|
||||||
|
setLoading(false);
|
||||||
|
if (!r.ok) { setError(r.message); return; }
|
||||||
|
setVoucher(r.data);
|
||||||
|
try {
|
||||||
|
const QR = (await import("qrcode")).default; // ถ้าไม่มี lib จะ throw แล้วจะไม่พังหน้า
|
||||||
|
if (canvasRef.current) {
|
||||||
|
await QR.toCanvas(canvasRef.current, r.data.code, { margin: 1, scale: 6 });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<section className="mx-auto flex min-h-screen max-w-sm flex-col items-center justify-center px-4 py-8">
|
||||||
|
{error && <div className="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||||
|
{voucher && (
|
||||||
|
<div className="w-full rounded-2xl border border-neutral-200 bg-white p-6 text-center shadow-sm">
|
||||||
|
<div className="text-xs text-neutral-500">คูปอง</div>
|
||||||
|
<h1 className="mt-1 text-xl font-extrabold">{voucher.title ?? "Voucher"}</h1>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col items-center">
|
||||||
|
<canvas ref={canvasRef} className="h-[240px] w-[240px] rounded-md border border-neutral-200" />
|
||||||
|
<div className="mt-3 font-mono text-2xl tracking-widest">{voucher.code}</div>
|
||||||
|
<div className="mt-1 text-xs text-neutral-500">สถานะ: {voucher.status === "active" ? "ใช้งานได้" : voucher.status === "used" ? "ใช้แล้ว" : "หมดอายุ"}</div>
|
||||||
|
{voucher.expireAt && (
|
||||||
|
<div className="text-xs text-neutral-500">หมดอายุ {new Date(voucher.expireAt).toLocaleDateString("th-TH", { dateStyle: "medium" })}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{voucher.terms && <div className="mt-4 rounded-lg bg-neutral-50 p-3 text-left text-xs text-neutral-700">{voucher.terms}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && <div className="text-sm text-neutral-500">กำลังโหลด…</div>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/app/(public)/member/vouchers/page.tsx
Normal file
91
src/app/(public)/member/vouchers/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import NextLink from "next/link";
|
||||||
|
|
||||||
|
const USE_MOCK4 = (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_USE_MOCK === "1") || true;
|
||||||
|
const MOCK_DELAY_MS4 = 400;
|
||||||
|
|
||||||
|
const sleep4 = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
type Voucher = { id: string; code: string; status: "active" | "used" | "expired"; expireAt?: string };
|
||||||
|
|
||||||
|
type ListResponse = { items: Voucher[] };
|
||||||
|
|
||||||
|
type ApiResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
|
||||||
|
|
||||||
|
const MOCK_LIST: ListResponse = {
|
||||||
|
items: [
|
||||||
|
{ id: "vch_9x27af", code: "AF-TH-9X27", status: "active", expireAt: new Date(Date.now() + 7 * 864e5).toISOString() },
|
||||||
|
{ id: "vch_zp31qt", code: "AF-TH-ZP31", status: "active", expireAt: new Date(Date.now() + 3 * 864e5).toISOString() },
|
||||||
|
{ id: "vch_old1", code: "AF-OLD-1", status: "used" },
|
||||||
|
{ id: "vch_old2", code: "AF-OLD-2", status: "expired" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getList(): Promise<ApiResult<ListResponse>> {
|
||||||
|
if (USE_MOCK4) {
|
||||||
|
await sleep4(MOCK_DELAY_MS4);
|
||||||
|
return { ok: true, data: MOCK_LIST } as const;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/member/vouchers", { cache: "no-store" });
|
||||||
|
const data = (await res.json()) as ListResponse;
|
||||||
|
if (!res.ok) return { ok: false, status: res.status, message: (data as any)?.message ?? res.statusText };
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, status: 0, message: e?.message ?? "Network error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(dt?: string) {
|
||||||
|
if (!dt) return "—";
|
||||||
|
try { return new Date(dt).toLocaleString("th-TH", { dateStyle: "medium" }); } catch { return dt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VouchersPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [list, setList] = useState<Voucher[]>([]);
|
||||||
|
const [tab, setTab] = useState<"active" | "used" | "expired">("active");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const r = await getList();
|
||||||
|
setLoading(false);
|
||||||
|
if (!r.ok) setError(r.message); else setList(r.data.items);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shown = useMemo(() => list.filter((v) => v.status === tab), [list, tab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<section className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-extrabold tracking-tight">คูปองของฉัน</h1>
|
||||||
|
<div className="mt-4 inline-flex rounded-full border border-neutral-200 p-1">
|
||||||
|
{(["active", "used", "expired"] as const).map((t) => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} className={`rounded-full px-4 py-2 text-sm font-semibold ${tab === t ? "bg-neutral-900 text-white" : "text-neutral-800"}`}>{t === "active" ? "ใช้งานได้" : t === "used" ? "ใช้แล้ว" : "หมดอายุ"}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-3">
|
||||||
|
{!loading && shown.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-700">ไม่มีคูปองในหมวดนี้</div>
|
||||||
|
)}
|
||||||
|
{shown.map((v) => (
|
||||||
|
<div key={v.id} className="flex items-center justify-between rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">รหัส: <span className="font-mono">{v.code}</span></div>
|
||||||
|
<div className="text-xs text-neutral-500">สถานะ: {v.status === "active" ? "ใช้งานได้" : v.status === "used" ? "ใช้แล้ว" : "หมดอายุ"}{v.expireAt ? ` • หมดอายุ ${fmtDate(v.expireAt)}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
<NextLink href={`/member/voucher/${encodeURIComponent(v.id)}`} className="rounded-full border border-neutral-300 px-4 py-2 text-sm font-semibold hover:bg-neutral-50">เปิดแสดง</NextLink>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user