From 332158ce1ee6fd6f50eda80815f849e9321e652d Mon Sep 17 00:00:00 2001 From: Thanakarn Klangkasame <77600906+Simulationable@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:41:16 +0700 Subject: [PATCH] Member System --- src/app/(public)/member/exchange/page.tsx | 141 ++++++ src/app/(public)/member/login/page.tsx | 118 +++++ src/app/(public)/member/logout/page.tsx | 19 + src/app/(public)/member/page.tsx | 428 ++++++++++++++++++ .../redeem}/page.tsx | 185 +++++--- src/app/(public)/member/voucher/[id]/page.tsx | 90 ++++ src/app/(public)/member/vouchers/page.tsx | 91 ++++ 7 files changed, 1003 insertions(+), 69 deletions(-) create mode 100644 src/app/(public)/member/exchange/page.tsx create mode 100644 src/app/(public)/member/login/page.tsx create mode 100644 src/app/(public)/member/logout/page.tsx create mode 100644 src/app/(public)/member/page.tsx rename src/app/(public)/{redeem/[transactionId] => member/redeem}/page.tsx (77%) create mode 100644 src/app/(public)/member/voucher/[id]/page.tsx create mode 100644 src/app/(public)/member/vouchers/page.tsx diff --git a/src/app/(public)/member/exchange/page.tsx b/src/app/(public)/member/exchange/page.tsx new file mode 100644 index 0000000..ce6235a --- /dev/null +++ b/src/app/(public)/member/exchange/page.tsx @@ -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 = { 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> { + 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> { + 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(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + const idemRef = useRef(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 ( +
+
+

แลกแต้มเป็นคูปอง/วอชเชอร์

+

เลือกข้อเสนอด้านล่างเพื่อแลกทันที

+ + {error &&
{error}
} + {successMsg &&
{successMsg}
} + +
+
คะแนนคงเหลือ
+
{loading ? "—" : summary?.pointsBalance?.toLocaleString("th-TH") ?? "0"}
+
+ +
+ {(summary?.offers ?? []).map((o) => ( + + ))} +
+ +
+ +
+
+
+ ); +} + diff --git a/src/app/(public)/member/login/page.tsx b/src/app/(public)/member/login/page.tsx new file mode 100644 index 0000000..ea37b6c --- /dev/null +++ b/src/app/(public)/member/login/page.tsx @@ -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 { + 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(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) { + 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 ( +
+
+ +

เข้าสู่ระบบสมาชิก

+

ไม่มี OTP • ใช้เบอร์ + PIN/รหัสผ่าน

+ +
+
+ + 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 && ( +
กรอกเป็นตัวเลข 10 หลัก
+ )} +
+
+ + 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 && ( +
อย่างน้อย 4 ตัวอักษร
+ )} +
+ {error && ( +
{error}
+ )} + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(public)/member/logout/page.tsx b/src/app/(public)/member/logout/page.tsx new file mode 100644 index 0000000..443b7bb --- /dev/null +++ b/src/app/(public)/member/logout/page.tsx @@ -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 ( +
+
กำลังออกจากระบบ…
+
+ ); +} diff --git a/src/app/(public)/member/page.tsx b/src/app/(public)/member/page.tsx new file mode 100644 index 0000000..5816fdd --- /dev/null +++ b/src/app/(public)/member/page.tsx @@ -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 = + | { 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(url: string): Promise> { + 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(url: string, init?: RequestInit): Promise> { + if (USE_MOCK) { + return mockFetch(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(null); + const [error, setError] = useState(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("/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 ( +
+
+ {/* Header */} +
+
+ +
+

ศูนย์สมาชิก

+

+ ยินดีต้อนรับ{summary?.name ? `, ${summary.name}` : ""}{" "} + {summary?.phoneLast4 ? `(ลงท้าย ${summary.phoneLast4})` : ""} +

+
+
+ +
+ + + ออกจากระบบ + +
+
+ + {/* Alerts */} + {error && ( +
{error}
+ )} + + {/* Overview cards */} +
+
+
คะแนนคงเหลือ
+
+ {loading ? "—" : summary?.pointsBalance?.toLocaleString("th-TH") ?? "0"} +
+
+ + แลกคูปอง/วอชเชอร์ → + +
+
+ +
+
คูปองของฉัน (ใช้งานได้)
+
+ {loading ? "—" : summary?.vouchers?.active ?? 0} +
+
+ + ดูทั้งหมด → + +
+
+ +
+
ธุรกรรมค้าง
+
+ {loading ? "—" : (summary?.pendingTransactions?.length ?? 0)} +
+
+ + เลื่อนไปดูรายการค้าง ↓ + +
+
+
+ + {/* Quick actions */} +
+
+
+ + 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" + /> +

+ ถ้าทำลิงก์หาย ให้กรอกหมายเลขธุรกรรมที่ได้รับจากหน้าคิดเงิน +

+
+ +
+ + ไปสะสมคะแนน + + + แลกแต้มเป็นคูปอง + +
+
+
+ + {/* Offers */} +
+

ข้อเสนอที่แลกได้

+
+ {(summary?.offers ?? []).length === 0 && !loading && ( +
+ ยังไม่มีข้อเสนอแนะนำในขณะนี้ +
+ )} + {(summary?.offers ?? []).map((o) => ( +
+
+
{o.title}
+
+ ต้องการ {o.pointsCost.toLocaleString("th-TH")} แต้ม +
+ {o.short &&
{o.short}
} +
+ + แลกเลย + +
+ ))} +
+
+ + {/* Latest vouchers */} +
+

คูปองล่าสุดของฉัน

+
+ {(!vouchersLatest || vouchersLatest.length === 0) && !loading && ( +
+ ยังไม่มีคูปองล่าสุด +
+ )} + {vouchersLatest?.map((v) => ( +
+
+
+ รหัส: {v.code} +
+
+ สถานะ: {v.status === "active" ? "ใช้งานได้" : v.status === "used" ? "ใช้แล้ว" : "หมดอายุ"} + {v.expireAt ? ` • หมดอายุ ${fmtDate(v.expireAt)}` : ""} +
+
+ + เปิดแสดง + +
+ ))} +
+
+ + {/* Pending transactions */} +
+

ธุรกรรมค้างของฉัน

+
+ {(summary?.pendingTransactions ?? []).length === 0 && !loading && ( +
+ ไม่มีธุรกรรมค้าง +
+ )} + {(summary?.pendingTransactions ?? []).map((p) => ( +
+
+
+ {p.type === "add" ? "เพิ่มแต้ม" : "แลกแต้ม"} •{" "} + {p.txnId} +
+
+ เวลา {fmtDate(p.createdAt)} + {typeof p.points === "number" + ? ` • ${p.points > 0 ? "+" : ""}${p.points.toLocaleString("th-TH")} แต้ม` + : ""} + {" • สถานะ "} + {p.status === "pending" ? "รอดำเนินการ" : p.status === "failed" ? "ล้มเหลว" : "หมดอายุ"} +
+
+
+ {p.type === "add" ? ( + + ดำเนินการต่อ + + ) : ( + + ดำเนินการต่อ + + )} +
+
+ ))} +
+
+ + {/* Footer */} +
+ © {new Date().getFullYear()} Aetherframe • Member Hub +
+
+
+ ); +} diff --git a/src/app/(public)/redeem/[transactionId]/page.tsx b/src/app/(public)/member/redeem/page.tsx similarity index 77% rename from src/app/(public)/redeem/[transactionId]/page.tsx rename to src/app/(public)/member/redeem/page.tsx index 2cb628d..5bdf342 100644 --- a/src/app/(public)/redeem/[transactionId]/page.tsx +++ b/src/app/(public)/member/redeem/page.tsx @@ -1,25 +1,26 @@ "use client"; -import {useMemo, useRef, useState} from "react"; -import {useParams, useSearchParams} from "next/navigation"; +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"; -import {resolveTenantKeyFromHost} from "@/types/tenant/tenant"; -import {onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode} from "@/modules/phone/utils"; -import type {RedeemResponse, RedeemRequest} from "@/types/crm"; +import { genIdempotencyKey } from "@/server/middleware/idempotency"; +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; }; + 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(null); const [result, setResult] = useState(null); - // UI branching const [showConsent, setShowConsent] = useState(false); - const [consentReason, setConsentReason] = useState(null); // e.g. "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" + const [consentReason, setConsentReason] = useState(null); - // idempotency for each call const idemStatusRef = useRef(null); const idemOnboardRef = useRef(null); const idemRedeemRef = useRef(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) { setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10)); } - function handleBeforeInput(e: React.FormEvent) { const nativeEvt = e.nativeEvent as unknown; const data = @@ -104,27 +109,80 @@ export default function RedeemPage() { ok: boolean; json?: any; text?: string; - err?: ApiErrorBody + err?: ApiErrorBody; }> { const txt = await res.text(); try { const json = txt ? JSON.parse(txt) : undefined; - if (res.ok) return {ok: true, json}; - return {ok: false, err: json ?? {message: txt}}; + if (res.ok) return { ok: true, json }; + return { ok: false, err: json ?? { message: txt } }; } catch { - if (res.ok) return {ok: true, text: txt}; - return {ok: false, err: {message: txt}}; + if (res.ok) return { ok: true, text: txt }; + return { ok: false, err: { message: txt } }; } } + async function mockGetStatus(): Promise { + 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 { + 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 { + 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 { + 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,10 +195,12 @@ export default function RedeemPage() { } async function onboard(tenantKey: string): Promise { + if (USE_MOCK) return mockOnboard(); + idemOnboardRef.current = genIdempotencyKey(); const payload: OnboardRequest = { - contact: {phone: phoneDigits}, - consent: agreeTerms ? {termsAccepted: true, marketingOptIn} : undefined, + contact: { phone: phoneDigits }, + consent: agreeTerms ? { termsAccepted: true, marketingOptIn } : undefined, metadata: { source: "qr-landing", consentAt: agreeTerms ? new Date().toISOString() : undefined, @@ -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,18 +225,20 @@ export default function RedeemPage() { } async function redeem(tenantKey: string): Promise { + if (USE_MOCK) return mockRedeem(); + idemRedeemRef.current = genIdempotencyKey(); const payload: RedeemRequest = { transactionId, - contact: {phone: phoneDigits}, - metadata: {source: "qr-landing"}, + contact: { phone: phoneDigits }, + metadata: { source: "qr-landing" }, }; const res = await fetch(`/api/public/redeem`, { method: "POST", 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,20 +268,19 @@ export default function RedeemPage() { setMessage(null); setResult(null); - const tenantKey = resolveTenantKeyFromHost(); + const tenantKey = resolveTenantKeyFromHost(); // backend จะอ่านจาก subdomain เป็นหลักอยู่แล้ว try { // Case A: มี transaction → พยายาม Redeem ก่อน แล้วค่อยถาม consent ถ้าจำเป็น if (transactionId) { try { const redeemRes = await redeem(tenantKey); - setResult({redeem: redeemRes}); + setResult({ redeem: redeemRes }); setMessage("สะสมคะแนนสำเร็จ"); setShowConsent(false); setConsentReason(null); return; } catch (exFirst: any) { - // ถ้าล้มเหลวเพราะต้องยินยอมหรือไม่มีบัญชี โชว์ consent แล้วรอให้ผู้ใช้ติ๊ก จากนั้น onboard และ redeem ซ้ำ if (!showConsent) { // โชว์ consent แล้วให้ผู้ใช้กดปุ่มเดิมอีกครั้งหลังติ๊ก setLoading(false); @@ -228,7 +289,7 @@ export default function RedeemPage() { // ผู้ใช้ติ๊กแล้ว → สร้าง/อัปเดต consent แล้ว Redeem ซ้ำ const onboarding = await onboard(tenantKey); const redeemRes = await redeem(tenantKey); - setResult({onboarding, redeem: redeemRes}); + setResult({ onboarding, redeem: redeemRes }); setMessage(onboarding.isNew ? "สมัครสมาชิกและสะสมคะแนนสำเร็จ" : "ยืนยันสมาชิกและสะสมคะแนนสำเร็จ"); setShowConsent(false); setConsentReason(null); @@ -238,7 +299,7 @@ export default function RedeemPage() { // Case B: ไม่มี transaction → ตรวจสถานะก่อน (มีบัญชี/ยินยอมแล้วหรือยัง) const status = await getStatus(tenantKey); - setResult({status}); + setResult({ status }); if (!status.exists || !status.hasLoyalty) { // ยังไม่มีบัญชี → ต้อง onboard (ต้องขอ consent) @@ -249,7 +310,7 @@ export default function RedeemPage() { return; } const onboarding = await onboard(tenantKey); - setResult({status, onboarding}); + setResult({ status, onboarding }); setMessage(onboarding.isNew ? "สมัครสมาชิกสำเร็จ" : "ยืนยันสมาชิกสำเร็จ"); setShowConsent(false); setConsentReason(null); @@ -265,14 +326,14 @@ export default function RedeemPage() { return; } const onboarding = await onboard(tenantKey); - setResult({status, onboarding}); + setResult({ status, onboarding }); setMessage("อัปเดตการยินยอมสำเร็จ"); setShowConsent(false); setConsentReason(null); return; } - // มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลลัพธ์สั้นๆ + // มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลสั้น ๆ setMessage("เข้าสู่ระบบสมาชิกสำเร็จ"); setShowConsent(false); setConsentReason(null); @@ -292,10 +353,10 @@ export default function RedeemPage() {
- +
-

Redeem / Join Loyalty

+

Redeem / Join

Transaction: {transactionId || "—"}

@@ -343,9 +404,7 @@ export default function RedeemPage() { {showConsent && (
- {consentReason === "CONTACT_NOT_FOUND" - ? "สมัครสมาชิกและยินยอมตามข้อตกลง" - : "อัปเดตการยินยอมตามข้อตกลง"} + {consentReason === "CONTACT_NOT_FOUND" ? "สมัครสมาชิกและยินยอมตามข้อตกลง" : "อัปเดตการยินยอมตามข้อตกลง"}
- {!agreeTerms && ( -
โปรดยอมรับข้อตกลงก่อนดำเนินการต่อ
- )} + {!agreeTerms &&
โปรดยอมรับข้อตกลงก่อนดำเนินการต่อ
}
)} @@ -397,15 +452,12 @@ export default function RedeemPage() { {message && ( -
+
{message}
)} {error && ( -
- {error} -
+
{error}
)} {result && ( @@ -419,8 +471,7 @@ export default function RedeemPage() {
ต้องยินยอม: {String(result.status.consentRequired)}
{typeof result.status.pointsBalance === "number" && (
- คะแนนคงเหลือ: {result.status.pointsBalance} + คะแนนคงเหลือ: {result.status.pointsBalance}
)}
@@ -435,15 +486,14 @@ export default function RedeemPage() { Contact: {result.onboarding.contactId}
- Loyalty: {result.onboarding.loyaltyAccountId} + Loyalty: {result.onboarding.loyaltyAccountId}
- {"pointsBalance" in result.onboarding && typeof result.onboarding.pointsBalance === "number" && ( -
- คะแนนคงเหลือ: {result.onboarding.pointsBalance} -
- )} + {"pointsBalance" in result.onboarding && + typeof result.onboarding.pointsBalance === "number" && ( +
+ คะแนนคงเหลือ: {result.onboarding.pointsBalance} +
+ )}
สถานะ: {result.onboarding.isNew ? "สร้างบัญชีใหม่" : "ยืนยันสมาชิกเดิม"}
@@ -455,23 +505,20 @@ export default function RedeemPage() {
{"balance" in result.redeem && (
- ยอดคงเหลือใหม่: {result.redeem.balance} + ยอดคงเหลือใหม่: {(result.redeem as any).balance}
)} {"voucherCode" in result.redeem && (
- รหัสคูปอง: {result.redeem.voucherCode} + รหัสคูปอง: {(result.redeem as any).voucherCode}
)} {"ledgerEntryId" in result.redeem && (
- Ledger: {result.redeem.ledgerEntryId} + Ledger: {(result.redeem as any).ledgerEntryId}
)} - {"redeemed" in result.redeem && -
สถานะ: {String(result.redeem.redeemed)}
} + {"redeemed" in result.redeem &&
สถานะ: {String((result.redeem as any).redeemed)}
}
)} diff --git a/src/app/(public)/member/voucher/[id]/page.tsx b/src/app/(public)/member/voucher/[id]/page.tsx new file mode 100644 index 0000000..c1d9ccc --- /dev/null +++ b/src/app/(public)/member/voucher/[id]/page.tsx @@ -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 = { ok: true; data: T } | { ok: false; status: number; message: string }; + +async function getDetail(id: string): Promise> { + 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(null); + const [voucher, setVoucher] = useState(null); + + // (ออปชัน) แสดง QR ด้วย dynamic import (ถ้ามีไลบรารี) + const canvasRef = useRef(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 ( +
+
+ {error &&
{error}
} + {voucher && ( +
+
คูปอง
+

{voucher.title ?? "Voucher"}

+ +
+ +
{voucher.code}
+
สถานะ: {voucher.status === "active" ? "ใช้งานได้" : voucher.status === "used" ? "ใช้แล้ว" : "หมดอายุ"}
+ {voucher.expireAt && ( +
หมดอายุ {new Date(voucher.expireAt).toLocaleDateString("th-TH", { dateStyle: "medium" })}
+ )} +
+ + {voucher.terms &&
{voucher.terms}
} +
+ )} + {loading &&
กำลังโหลด…
} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(public)/member/vouchers/page.tsx b/src/app/(public)/member/vouchers/page.tsx new file mode 100644 index 0000000..bf9197b --- /dev/null +++ b/src/app/(public)/member/vouchers/page.tsx @@ -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 = { 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> { + 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(null); + const [list, setList] = useState([]); + 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 ( +
+
+

คูปองของฉัน

+
+ {(["active", "used", "expired"] as const).map((t) => ( + + ))} +
+ + {error &&
{error}
} + +
+ {!loading && shown.length === 0 && ( +
ไม่มีคูปองในหมวดนี้
+ )} + {shown.map((v) => ( +
+
+
รหัส: {v.code}
+
สถานะ: {v.status === "active" ? "ใช้งานได้" : v.status === "used" ? "ใช้แล้ว" : "หมดอายุ"}{v.expireAt ? ` • หมดอายุ ${fmtDate(v.expireAt)}` : ""}
+
+ เปิดแสดง +
+ ))} +
+
+
+ ); +} \ No newline at end of file