Member System

This commit is contained in:
Thanakarn Klangkasame
2025-10-14 14:41:16 +07:00
parent 9145e48d7d
commit 332158ce1e
7 changed files with 1003 additions and 69 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import {useMemo, useRef, useState} from "react";
import React, { useMemo, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import BrandLogo from "@/app/component/common/BrandLogo";
import { genIdempotencyKey } from "@/server/middleware/idempotency";
@@ -8,18 +8,19 @@ import {resolveTenantKeyFromHost} from "@/types/tenant/tenant";
import { onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode } from "@/modules/phone/utils";
import type { RedeemResponse, RedeemRequest } from "@/types/crm";
/* ===== Types ===== */
type OnboardRequest = {
contact: { phone: string };
consent?: { termsAccepted: boolean; marketingOptIn?: boolean };
metadata?: Record<string, unknown>;
};
type OnboardResponse = {
contactId: string;
loyaltyAccountId: string;
isNew: boolean;
pointsBalance?: number;
};
type StatusResponse = {
exists: boolean;
hasLoyalty: boolean;
@@ -28,6 +29,7 @@ type StatusResponse = {
loyaltyAccountId?: string;
pointsBalance?: number;
};
type CombinedResult = {
status?: StatusResponse;
onboarding?: OnboardResponse;
@@ -36,11 +38,19 @@ type CombinedResult = {
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() {
const params = useParams<{ transactionId: string }>();
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 [agreeTerms, setAgreeTerms] = useState(false);
@@ -51,16 +61,13 @@ export default function RedeemPage() {
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<CombinedResult | null>(null);
// UI branching
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 idemOnboardRef = useRef<string | null>(null);
const idemRedeemRef = useRef<string | null>(null);
/* ===== Validation ===== */
const phoneError = useMemo(() => {
if (!phoneDigits) return null;
if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
@@ -73,11 +80,9 @@ export default function RedeemPage() {
const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits);
const canSubmit = phoneValid && !loading && (!showConsent || (showConsent && agreeTerms));
/* ===== Input guards ===== */
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
}
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
const nativeEvt = e.nativeEvent as unknown;
const data =
@@ -104,7 +109,7 @@ export default function RedeemPage() {
ok: boolean;
json?: any;
text?: string;
err?: ApiErrorBody
err?: ApiErrorBody;
}> {
const txt = await res.text();
try {
@@ -117,14 +122,67 @@ export default function RedeemPage() {
}
}
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> {
if (USE_MOCK) return mockGetStatus();
idemStatusRef.current = genIdempotencyKey();
const url = `/api/public/loyalty/status?phone=${encodeURIComponent(phoneDigits)}`;
const res = await fetch(url, {
method: "GET",
headers: {
"X-Tenant-Key": tenantKey,
"Idempotency-Key": idemStatusRef.current!,
"X-Tenant-Key": tenantKey, // API ฝั่ง server ควร resolve จาก subdomain เป็นหลัก ส่วน header นี้เป็น optional
"X-Idempotency-Key": idemStatusRef.current!, // เปลี่ยนชื่อ header ให้สอดคล้อง
},
cache: "no-store",
});
@@ -137,6 +195,8 @@ export default function RedeemPage() {
}
async function onboard(tenantKey: string): Promise<OnboardResponse> {
if (USE_MOCK) return mockOnboard();
idemOnboardRef.current = genIdempotencyKey();
const payload: OnboardRequest = {
contact: { phone: phoneDigits },
@@ -151,7 +211,7 @@ export default function RedeemPage() {
headers: {
"Content-Type": "application/json",
"X-Tenant-Key": tenantKey,
"Idempotency-Key": idemOnboardRef.current!,
"X-Idempotency-Key": idemOnboardRef.current!,
},
body: JSON.stringify(payload),
cache: "no-store",
@@ -165,6 +225,8 @@ export default function RedeemPage() {
}
async function redeem(tenantKey: string): Promise<RedeemResponse> {
if (USE_MOCK) return mockRedeem();
idemRedeemRef.current = genIdempotencyKey();
const payload: RedeemRequest = {
transactionId,
@@ -176,7 +238,7 @@ export default function RedeemPage() {
headers: {
"Content-Type": "application/json",
"X-Tenant-Key": tenantKey,
"Idempotency-Key": idemRedeemRef.current!,
"X-Idempotency-Key": idemRedeemRef.current!,
},
body: JSON.stringify(payload),
cache: "no-store",
@@ -206,7 +268,7 @@ export default function RedeemPage() {
setMessage(null);
setResult(null);
const tenantKey = resolveTenantKeyFromHost();
const tenantKey = resolveTenantKeyFromHost(); // backend จะอ่านจาก subdomain เป็นหลักอยู่แล้ว
try {
// Case A: มี transaction → พยายาม Redeem ก่อน แล้วค่อยถาม consent ถ้าจำเป็น
@@ -219,7 +281,6 @@ export default function RedeemPage() {
setConsentReason(null);
return;
} catch (exFirst: any) {
// ถ้าล้มเหลวเพราะต้องยินยอมหรือไม่มีบัญชี โชว์ consent แล้วรอให้ผู้ใช้ติ๊ก จากนั้น onboard และ redeem ซ้ำ
if (!showConsent) {
// โชว์ consent แล้วให้ผู้ใช้กดปุ่มเดิมอีกครั้งหลังติ๊ก
setLoading(false);
@@ -272,7 +333,7 @@ export default function RedeemPage() {
return;
}
// มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลลัพธ์สั้นๆ
// มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลสั้น
setMessage("เข้าสู่ระบบสมาชิกสำเร็จ");
setShowConsent(false);
setConsentReason(null);
@@ -295,7 +356,7 @@ export default function RedeemPage() {
<BrandLogo />
</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">
Transaction: <span className="font-mono">{transactionId || "—"}</span>
</p>
@@ -343,9 +404,7 @@ export default function RedeemPage() {
{showConsent && (
<div className="space-y-3 rounded-xl bg-neutral-50 p-4">
<div className="text-sm font-medium text-neutral-800">
{consentReason === "CONTACT_NOT_FOUND"
? "สมัครสมาชิกและยินยอมตามข้อตกลง"
: "อัปเดตการยินยอมตามข้อตกลง"}
{consentReason === "CONTACT_NOT_FOUND" ? "สมัครสมาชิกและยินยอมตามข้อตกลง" : "อัปเดตการยินยอมตามข้อตกลง"}
</div>
<label className="flex items-start gap-3">
<input
@@ -365,13 +424,9 @@ export default function RedeemPage() {
checked={marketingOptIn}
onChange={(e) => setMarketingOptIn(e.target.checked)}
/>
<span className="text-sm text-neutral-700">
/
</span>
<span className="text-sm text-neutral-700">/</span>
</label>
{!agreeTerms && (
<div className="text-xs text-red-600"></div>
)}
{!agreeTerms && <div className="text-xs text-red-600"></div>}
</div>
)}
@@ -397,15 +452,12 @@ export default function RedeemPage() {
</form>
{message && (
<div
className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
{message}
</div>
)}
{error && (
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>
)}
{result && (
@@ -419,8 +471,7 @@ export default function RedeemPage() {
<div>: {String(result.status.consentRequired)}</div>
{typeof result.status.pointsBalance === "number" && (
<div>
: <span
className="font-semibold">{result.status.pointsBalance}</span>
: <span className="font-semibold">{result.status.pointsBalance}</span>
</div>
)}
</div>
@@ -435,13 +486,12 @@ export default function RedeemPage() {
Contact: <span className="font-mono">{result.onboarding.contactId}</span>
</div>
<div>
Loyalty: <span
className="font-mono">{result.onboarding.loyaltyAccountId}</span>
Loyalty: <span className="font-mono">{result.onboarding.loyaltyAccountId}</span>
</div>
{"pointsBalance" in result.onboarding && typeof result.onboarding.pointsBalance === "number" && (
{"pointsBalance" in result.onboarding &&
typeof result.onboarding.pointsBalance === "number" && (
<div>
: <span
className="font-semibold">{result.onboarding.pointsBalance}</span>
: <span className="font-semibold">{result.onboarding.pointsBalance}</span>
</div>
)}
<div>: {result.onboarding.isNew ? "สร้างบัญชีใหม่" : "ยืนยันสมาชิกเดิม"}</div>
@@ -455,23 +505,20 @@ export default function RedeemPage() {
<div className="space-y-1">
{"balance" in result.redeem && (
<div>
: <span
className="font-semibold">{result.redeem.balance}</span>
: <span className="font-semibold">{(result.redeem as any).balance}</span>
</div>
)}
{"voucherCode" in result.redeem && (
<div>
: <span
className="font-mono">{result.redeem.voucherCode}</span>
: <span className="font-mono">{(result.redeem as any).voucherCode}</span>
</div>
)}
{"ledgerEntryId" in result.redeem && (
<div>
Ledger: <span className="font-mono">{result.redeem.ledgerEntryId}</span>
Ledger: <span className="font-mono">{(result.redeem as any).ledgerEntryId}</span>
</div>
)}
{"redeemed" in result.redeem &&
<div>: {String(result.redeem.redeemed)}</div>}
{"redeemed" in result.redeem && <div>: {String((result.redeem as any).redeemed)}</div>}
</div>
</div>
)}

View 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>
);
}

View 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>
);
}