From 9145e48d7de03b53154119caa48572dc2df08046 Mon Sep 17 00:00:00 2001 From: Thanakarn Klangkasame <77600906+Simulationable@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:58:10 +0700 Subject: [PATCH] Redesign Menu --- src/app/(protected)/layout.tsx | 29 +- src/app/(protected)/products/page.tsx | 1041 +---------- .../(public)/redeem/[transactionId]/page.tsx | 407 ++++- src/app/api/auth/login/route.ts | 2 +- src/app/api/auth/refresh/route.ts | 4 +- src/app/api/proxy/[...path]/route.ts | 4 +- src/app/component/layout/AppShell.tsx | 493 +++-- src/app/component/product/ProductList.tsx | 1602 +++++++++++++++++ src/{lib/config.ts => config/env.ts} | 0 src/{lib => modules/navigation}/nav.ts | 160 +- src/{lib/phone.ts => modules/phone/utils.ts} | 2 +- .../server-auth.ts => server/auth/service.ts} | 29 +- src/{lib => server/middleware}/idempotency.ts | 0 .../tenant/service.ts} | 12 +- src/types/auth/index.ts | 8 +- src/types/auth/upstream.ts | 19 - .../auth/upstream/upstreamLoginResponse.ts | 8 + src/types/auth/upstream/upstreamLoginUser.ts | 6 + .../auth/upstream/upstreamRefreshResponse.ts | 5 + src/types/auth/user/claims.ts | 15 + src/types/auth/{user.ts => user/userDebug.ts} | 13 +- src/types/auth/user/userSession.ts | 10 + src/types/product/index.ts | 1 + src/types/product/product.ts | 164 ++ src/types/tenant/index.ts | 1 + src/{lib => types/tenant}/tenant.ts | 0 26 files changed, 2649 insertions(+), 1386 deletions(-) create mode 100644 src/app/component/product/ProductList.tsx rename src/{lib/config.ts => config/env.ts} (100%) rename src/{lib => modules/navigation}/nav.ts (70%) rename src/{lib/phone.ts => modules/phone/utils.ts} (77%) rename src/{lib/server-auth.ts => server/auth/service.ts} (86%) rename src/{lib => server/middleware}/idempotency.ts (100%) rename src/{lib/server-tenant.ts => server/tenant/service.ts} (73%) delete mode 100644 src/types/auth/upstream.ts create mode 100644 src/types/auth/upstream/upstreamLoginResponse.ts create mode 100644 src/types/auth/upstream/upstreamLoginUser.ts create mode 100644 src/types/auth/upstream/upstreamRefreshResponse.ts create mode 100644 src/types/auth/user/claims.ts rename src/types/auth/{user.ts => user/userDebug.ts} (51%) create mode 100644 src/types/auth/user/userSession.ts create mode 100644 src/types/product/index.ts create mode 100644 src/types/product/product.ts create mode 100644 src/types/tenant/index.ts rename src/{lib => types/tenant}/tenant.ts (100%) diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index 8413b69..6d3e89c 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -1,12 +1,16 @@ -import { ReactNode } from "react"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; +// src/app/(protected)/layout.tsx (หรือไฟล์ ProtectedLayout ของคุณ) +import {ReactNode} from "react"; +import {cookies} from "next/headers"; +import {redirect} from "next/navigation"; import AppShell from "@/app/component/layout/AppShell"; -import { getUserFromToken } from "@/lib/server-auth"; -import { buildNavForRoles } from "@/lib/nav"; -import type { UserSession } from "@/types/auth"; +import {getUserFromToken} from "@/server/auth/service"; +import { + buildNavForRoles, + buildSidebarTreeForRoles, +} from "@/modules/navigation/nav"; +import type {UserSession} from "@/types/auth"; -export default async function ProtectedLayout({ children }: { children: ReactNode }) { +export default async function ProtectedLayout({children}: { children: ReactNode }) { const cookieStore = await cookies(); const access = cookieStore.get("access_token")?.value; @@ -39,13 +43,12 @@ export default async function ProtectedLayout({ children }: { children: ReactNod redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`); } - const nav = buildNavForRoles(merged.roles, [], { showAll: true }); + const nav = buildNavForRoles(merged.roles, [], {showAll: true}); + const tree = buildSidebarTreeForRoles(merged.roles, [], {showAll: true}); return ( - -
- {children} -
+ + {children} ); -} \ No newline at end of file +} diff --git a/src/app/(protected)/products/page.tsx b/src/app/(protected)/products/page.tsx index c7e8cea..b138ce0 100644 --- a/src/app/(protected)/products/page.tsx +++ b/src/app/(protected)/products/page.tsx @@ -1,1038 +1,5 @@ -"use client"; +import ProductList from "@/app/component/product/ProductList"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import type { LucideIcon } from "lucide-react"; -import { - Package, Tag, - CheckCircle2, Hourglass, RotateCcw, Ban, - ShieldCheck, ShieldAlert, ShieldX, - FileText, Image as ImageIcon, Link as LinkIcon, - Search, Eraser, X as XIcon -} from "lucide-react"; - -/* ======================== Types ======================== */ -type ProductType = "simple" | "variant" | "bundle"; -type LifecycleStatus = "draft" | "active" | "archived" | "discontinued"; -type ComplianceStatus = "na" | "pending" | "approved" | "rejected"; -type QaStage = "idea" | "lab" | "pilot" | "approved" | "deprecated"; -type ChannelFilter = "all" | "d2c" | "shopee" | "lazada" | "tiktok" | "none"; - -type Product = { - /* เอกลักษณ์ & โครงสร้าง */ - id: string; - productCode: string; // รหัสสินค้าแม่ - parentProductId?: string | null; // ถ้าเป็น variant อ้างอิงแม่ - sku: string; // SKU ระดับ variant - gtin?: string | null; - type: ProductType; - attributesSummary?: string | null; - - brandId?: string | null; - brand: string; - categoryId?: string | null; - category: string; - categoryPath?: string | null; - - /* คอนเทนต์ */ - productName: string; - variantName?: string | null; - descriptionShort?: string | null; - slug?: string | null; - - /* หน่วย & ขนาด */ - uom: string; - weightGrams?: number | null; - dimLcm?: number | null; - dimWcm?: number | null; - dimHcm?: number | null; - netVolumeMl?: number | null; - - /* ราคา/การตลาด (สรุป) */ - currency?: "THB" | "USD" | "JPY" | "EUR"; - listPrice?: number | null; - compareAtPrice?: number | null; - priceListsCount?: number | null; - - /* สื่อ & เอกสาร/SEO */ - imageCount?: number | null; - docsCount?: number | null; - seoTitle?: string | null; - - /* ช่องทาง/ลิสติ้ง */ - d2cPublished?: boolean; - shopeePublished?: boolean; - lazadaPublished?: boolean; - tiktokPublished?: boolean; - channelsPublished?: string | null; - d2cUrl?: string | null; - shopeeListingId?: string | null; - lazadaListingId?: string | null; - tiktokListingId?: string | null; - - /* กฎหมาย/ความสอดคล้อง */ - complianceStatus: ComplianceStatus; - thaiFdaNo?: string | null; - hazardClass?: string | null; - storageInstruction?: string | null; - shelfLifeMonths?: number | null; - ageRestrictionMin?: number | null; - allergens?: string[] | null; - certificationsCount?: number | null; - msdsUrl?: string | null; - - /* R&D / สูตร */ - qaStage?: QaStage; - formulationVersion?: string | null; - ingredientsCount?: number | null; - rdOwner?: string | null; - - /* บรรจุภัณฑ์ */ - packLevels?: number | null; // 1=Unit, 2=Unit+Case, 3=Unit+Inner+Case - packPrimaryBarcode?: string | null; // GTIN Unit - packCaseBarcode?: string | null; // GTIN Case - packNotes?: string | null; - - /* ซัพพลาย/การผลิต */ - manufacturer?: string | null; - mfgCountry?: string | null; - mfgLeadTimeDays?: number | null; - moq?: number | null; - - /* วงจรชีวิต */ - status: LifecycleStatus; - publishedAt?: string | null; - createdAt: string; - updatedAt?: string | null; - archivedAt?: string | null; - - /* อื่น ๆ */ - tags?: string[]; - note?: string | null; -}; - -type ColumnKey = keyof Product; -type Column = { - key: ColumnKey; - labelTH: string; - groupTH: string; - align?: "left" | "right"; - format?: (v: Product[ColumnKey], row: Product) => ReactNode; -}; - -/* ======================== Meta & helpers ======================== */ -type Tone = "ok" | "warn" | "bad" | "neutral"; -const pill = (t: Tone) => - ({ - ok: "bg-emerald-50 text-emerald-700 border border-emerald-200", - warn: "bg-amber-50 text-amber-700 border border-amber-200", - bad: "bg-red-50 text-red-700 border border-red-200", - neutral: "bg-neutral-50 text-neutral-700 border border-neutral-200", - }[t]); - -const U = (s: string) => s.toUpperCase(); - -// เส้นคอลัมน์โทนอ่อน (ใช้กับทั้ง th/td) -const COL_BORDER = "border-r border-neutral-200/60 last:border-r-0"; - -const IconLabel = ({ Icon, label, pillTone }: { Icon: LucideIcon; label: string; pillTone?: Tone }) => { - const content = ( - - - {label} - - ); - if (!pillTone) return content; - return ( - - - {label} - - ); -}; - -/* Renderers */ -const TYPE_META: Record = { - simple: { label: U("Simple"), Icon: Package }, - variant: { label: U("Variant"), Icon: Tag }, - bundle: { label: U("Bundle"), Icon: Package }, -}; -const renderType = (t: ProductType) => { - const { Icon, label } = TYPE_META[t]; - return ; -}; - -const LIFECYCLE_META: Record = { - draft: { label: U("Draft"), Icon: Hourglass, tone: "warn" }, - active: { label: U("Active"), Icon: CheckCircle2, tone: "ok" }, - archived: { label: U("Archived"), Icon: RotateCcw, tone: "neutral" }, - discontinued: { label: U("Discontinued"), Icon: Ban, tone: "bad" }, -}; -const renderLifecycle = (s: LifecycleStatus) => { - const { Icon, label, tone } = LIFECYCLE_META[s]; - return ; -}; - -const COMPLIANCE_META: Record = { - na: { label: U("N/A"), Icon: ShieldAlert, tone: "neutral" }, - pending: { label: U("Pending"), Icon: ShieldAlert, tone: "warn" }, - approved: { label: U("Approved"),Icon: ShieldCheck, tone: "ok" }, - rejected: { label: U("Rejected"),Icon: ShieldX, tone: "bad" }, -}; -const renderCompliance = (s: ComplianceStatus) => { - const { Icon, label, tone } = COMPLIANCE_META[s]; - return ; -}; - -const fmtDate = (iso?: string | null) => (iso ? new Date(iso).toLocaleString("th-TH", { hour12: false }) : "-"); -const THB = (n?: number | null) => (typeof n === "number" ? `฿${n.toLocaleString()}` : "-"); -const num = (n?: number | null) => (typeof n === "number" && Number.isFinite(n) ? n.toString() : "-"); - -function getProp(obj: T, key: K): T[K] { return obj[key]; } -function escapeCsv(v: unknown): string { - if (v === null || v === undefined) return ""; - const s = typeof v === "string" ? v : Array.isArray(v) ? v.join("|") : String(v); - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; -} -function downloadCsv(filename: string, rows: Product[], cols: Column[]) { - if (!rows.length || !cols.length) return; - const headers = cols.map((c) => c.labelTH); - const keys = cols.map((c) => c.key); - const csv = - headers.join(",") + "\n" + - rows.map((r) => keys.map((k) => escapeCsv(getProp(r, k))).join(",")).join("\n"); - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); - URL.revokeObjectURL(url); -} -function defaultCell(v: unknown): ReactNode { - if (v === null || v === undefined) return "-"; - if (typeof v === "string") return v.trim() === "" ? "-" : v; - if (typeof v === "number") return Number.isFinite(v) ? v.toString() : "-"; - if (typeof v === "boolean") return v ? "YES" : "NO"; - if (Array.isArray(v)) return v.length ? v.join("|") : "-"; - return String(v); -} - -/* ======================== Mock Data (ตัวอย่าง) ======================== */ -const MOCK_PRODUCTS: Product[] = [ - { - id: "P-1001", - productCode: "AMZ-COLL-BASE", - parentProductId: null, - sku: "AMZ-COLL-BASE-250G", - gtin: "08850001112223", - type: "variant", - attributesSummary: "Weight=250g|Flavor=Unflavored", - - brandId: "BRD-0001", - brand: "Amrez", - categoryId: "CAT-0002", - category: "Supplements > Collagen", - categoryPath: "Supplements>Collagen", - - productName: "Amrez Collagen Peptides", - variantName: "250g Pouch", - descriptionShort: "คอลลาเจนเพปไทด์ ไฮโดรไลซ์", - slug: "amrez-collagen-250g", - - uom: "ชิ้น", - weightGrams: 280, - dimLcm: 20, dimWcm: 14, dimHcm: 6, - netVolumeMl: null, - - currency: "THB", - listPrice: 790, - compareAtPrice: 890, - priceListsCount: 3, - - imageCount: 5, - docsCount: 3, - seoTitle: "คอลลาเจน Amrez 250 กรัม", - - d2cPublished: true, - shopeePublished: true, - lazadaPublished: true, - tiktokPublished: false, - channelsPublished: "D2C|Shopee|Lazada", - d2cUrl: "/p/amrez-collagen-250g", - shopeeListingId: "SP-556677", - lazadaListingId: "LZ-889900", - tiktokListingId: null, - - complianceStatus: "approved", - thaiFdaNo: "11-1-12345-5-0001", - hazardClass: "Non-hazard", - storageInstruction: "เก็บให้พ้นแสงแดด", - shelfLifeMonths: 24, - ageRestrictionMin: 12, - allergens: [], - certificationsCount: 2, - msdsUrl: null, - - qaStage: "approved", - formulationVersion: "v1.3", - ingredientsCount: 8, - rdOwner: "Korn P.", - - packLevels: 2, - packPrimaryBarcode: "08850001112223", - packCaseBarcode: "18850001112220", - packNotes: "12 units/case", - - manufacturer: "Amrez Foods Co., Ltd.", - mfgCountry: "TH", - mfgLeadTimeDays: 21, - moq: 500, - - status: "active", - publishedAt: "2025-08-15T09:00:00+07:00", - createdAt: "2025-07-10T15:21:00+07:00", - updatedAt: "2025-10-07T10:10:00+07:00", - archivedAt: null, - - tags: ["top_seller", "online_only"], - note: null, - }, - { - id: "P-1002", - productCode: "AMZ-SERUM-VC", - parentProductId: null, - sku: "AMZ-SERUM-VC-30ML", - gtin: "08850001113334", - type: "simple", - attributesSummary: null, - - brandId: "BRD-0002", - brand: "Kathy Labz", - categoryId: "CAT-0101", - category: "Beauty > Serum", - categoryPath: "Beauty>Serum", - - productName: "Vitamin C Bright Serum 30 ml", - variantName: null, - descriptionShort: "วิตซี + HA", - slug: "vitamin-c-bright-serum-30ml", - - uom: "ขวด", - weightGrams: 120, - dimLcm: 14, dimWcm: 5, dimHcm: 5, - netVolumeMl: 30, - - currency: "THB", - listPrice: 590, - compareAtPrice: null, - priceListsCount: 2, - - imageCount: 4, - docsCount: 2, - seoTitle: "เซรั่มวิตซี 30ml", - - d2cPublished: false, - shopeePublished: false, - lazadaPublished: false, - tiktokPublished: false, - channelsPublished: "", - d2cUrl: null, shopeeListingId: null, lazadaListingId: null, tiktokListingId: null, - - complianceStatus: "pending", - thaiFdaNo: null, - hazardClass: "Non-hazard", - storageInstruction: "ปิดฝาให้สนิท", - shelfLifeMonths: 18, - ageRestrictionMin: 12, - allergens: ["Fragrance"], - certificationsCount: 1, - msdsUrl: "https://example.com/msds/serum-vc.pdf", - - qaStage: "lab", - formulationVersion: "v0.9", - ingredientsCount: 15, - rdOwner: "Bee T.", - - packLevels: 2, - packPrimaryBarcode: "08850001113334", - packCaseBarcode: "18850001113331", - packNotes: "24 units/case", - - manufacturer: "Kathy Labz Lab", - mfgCountry: "TH", - mfgLeadTimeDays: 35, - moq: 1000, - - status: "draft", - publishedAt: null, - createdAt: "2025-09-01T11:00:00+07:00", - updatedAt: "2025-10-08T09:30:00+07:00", - archivedAt: null, - - tags: ["r&d", "new"], - note: "รอเลข อย.", - }, - { - id: "P-1003", - productCode: "AMZ-GIFT-SET-01", - parentProductId: null, - sku: "AMZ-GIFT-SET-01", - gtin: null, - type: "bundle", - attributesSummary: "Bundle of: COLL-250G + VC-30ML", - - brandId: "BRD-0001", - brand: "Amrez", - categoryId: "CAT-0901", - category: "Sets & Bundles", - categoryPath: "Sets&Bundles", - - productName: "Amrez Glow Gift Set", - variantName: null, - descriptionShort: "ชุดของขวัญผิวโกลว์", - slug: "amrez-glow-gift-set", - - uom: "ชุด", - weightGrams: 420, - dimLcm: 24, dimWcm: 18, dimHcm: 8, - netVolumeMl: null, - - currency: "THB", - listPrice: 1290, - compareAtPrice: 1480, - priceListsCount: 2, - - imageCount: 6, - docsCount: 1, - seoTitle: "ชุดของขวัญผิวโกลว์", - - d2cPublished: true, - shopeePublished: true, - lazadaPublished: false, - tiktokPublished: true, - channelsPublished: "D2C|Shopee|TikTok", - d2cUrl: "/p/amrez-glow-gift-set", - shopeeListingId: "SP-777888", - lazadaListingId: null, - tiktokListingId: "TT-445566", - - complianceStatus: "na", - thaiFdaNo: null, - hazardClass: "Non-hazard", - storageInstruction: "—", - shelfLifeMonths: 24, - ageRestrictionMin: null, - allergens: null, - certificationsCount: 0, - msdsUrl: null, - - qaStage: "approved", - formulationVersion: null, - ingredientsCount: null, - rdOwner: "Aom S.", - - packLevels: 2, - packPrimaryBarcode: null, - packCaseBarcode: null, - packNotes: "ชุดบันเดิล", - - manufacturer: "Amrez Assembly", - mfgCountry: "TH", - mfgLeadTimeDays: 10, - moq: 200, - - status: "active", - publishedAt: "2025-10-01T08:00:00+07:00", - createdAt: "2025-09-20T10:10:00+07:00", - updatedAt: "2025-10-08T12:00:00+07:00", - archivedAt: null, - - tags: ["bundle", "gift"], - note: null, - }, -]; - -/* ======================== Columns (ขยายชุดใหญ่) ======================== */ -const ALL_COLS: Column[] = [ - // เอกลักษณ์ & โครงสร้าง - { key: "id", labelTH: "ไอดี", groupTH: "เอกลักษณ์ & โครงสร้าง" }, - { key: "productCode", labelTH: "รหัสสินค้าแม่", groupTH: "เอกลักษณ์ & โครงสร้าง" }, - { key: "parentProductId", labelTH: "รหัสแม่ (ถ้ามี)", groupTH: "เอกลักษณ์ & โครงสร้าง" }, - { key: "type", labelTH: "ชนิดสินค้า", groupTH: "เอกลักษณ์ & โครงสร้าง", format: (v) => renderType(v as ProductType) }, - { key: "sku", labelTH: "SKU (Variant)", groupTH: "เอกลักษณ์ & โครงสร้าง" }, - { key: "gtin", labelTH: "GTIN", groupTH: "เอกลักษณ์ & โครงสร้าง" }, - { key: "attributesSummary", labelTH: "แอตทริบิวต์", groupTH: "เอกลักษณ์ & โครงสร้าง" }, - - // แบรนด์/หมวด - { key: "brand", labelTH: "แบรนด์", groupTH: "แบรนด์/หมวด" }, - { key: "brandId", labelTH: "รหัสแบรนด์", groupTH: "แบรนด์/หมวด" }, - { key: "category", labelTH: "หมวดหมู่", groupTH: "แบรนด์/หมวด" }, - { key: "categoryPath", labelTH: "Taxonomy Path", groupTH: "แบรนด์/หมวด" }, - { key: "categoryId", labelTH: "รหัสหมวด", groupTH: "แบรนด์/หมวด" }, - - // คอนเทนต์ - { key: "productName", labelTH: "ชื่อสินค้า", groupTH: "คอนเทนต์" }, - { key: "variantName", labelTH: "ชื่อรุ่นย่อย", groupTH: "คอนเทนต์" }, - { key: "descriptionShort", labelTH: "คำอธิบายสั้น", groupTH: "คอนเทนต์" }, - { key: "slug", labelTH: "Slug", groupTH: "คอนเทนต์" }, - - // หน่วย & ขนาด - { key: "uom", labelTH: "หน่วยนับ", groupTH: "หน่วย & ขนาด" }, - { key: "weightGrams", labelTH: "น้ำหนัก(g)", groupTH: "หน่วย & ขนาด", align: "right" }, - { key: "dimLcm", labelTH: "ยาว(cm)", groupTH: "หน่วย & ขนาด", align: "right" }, - { key: "dimWcm", labelTH: "กว้าง(cm)", groupTH: "หน่วย & ขนาด", align: "right" }, - { key: "dimHcm", labelTH: "สูง(cm)", groupTH: "หน่วย & ขนาด", align: "right" }, - { key: "netVolumeMl", labelTH: "ปริมาตร(ml)", groupTH: "หน่วย & ขนาด", align: "right" }, - - // ราคา/การตลาด - { key: "currency", labelTH: "สกุล", groupTH: "ราคา/การตลาด" }, - { key: "listPrice", labelTH: "ราคา", groupTH: "ราคา/การตลาด", align: "right", format: (v) => THB(v as number | null | undefined) }, - { key: "compareAtPrice", labelTH: "ราคาเทียบ", groupTH: "ราคา/การตลาด", align: "right", format: (v) => THB(v as number | null | undefined) }, - { key: "priceListsCount", labelTH: "จำนวนราคาลิสต์", groupTH: "ราคา/การตลาด", align: "right" }, - - // สื่อ & เอกสาร/SEO - { key: "imageCount", labelTH: "จำนวนสื่อ", groupTH: "สื่อ/เอกสาร/SEO", align: "right", format: (v) => ( - {num(v as number)} - ) }, - { key: "docsCount", labelTH: "จำนวนเอกสาร", groupTH: "สื่อ/เอกสาร/SEO", align: "right", format: (v) => ( - {num(v as number)} - ) }, - { key: "seoTitle", labelTH: "SEO Title", groupTH: "สื่อ/เอกสาร/SEO" }, - - // ช่องทาง/ลิสติ้ง - { key: "channelsPublished", labelTH: "เผยแพร่ที่", groupTH: "ช่องทาง/ลิสติ้ง" }, - { key: "d2cPublished", labelTH: "D2C", groupTH: "ช่องทาง/ลิสติ้ง" }, - { key: "shopeePublished", labelTH: "Shopee", groupTH: "ช่องทาง/ลิสติ้ง" }, - { key: "lazadaPublished", labelTH: "Lazada", groupTH: "ช่องทาง/ลิสติ้ง" }, - { key: "tiktokPublished", labelTH: "TikTok", groupTH: "ช่องทาง/ลิสติ้ง" }, - { key: "d2cUrl", labelTH: "D2C URL", groupTH: "ช่องทาง/ลิสติ้ง", format: (v) => v ? ( - - เปิด - - ) : "-" }, - { key: "shopeeListingId", labelTH: "Shopee ID", groupTH: "ช่องทาง/ลิสติ้ง" }, - { key: "lazadaListingId", labelTH: "Lazada ID", groupTH: "ช่องทาง/ลิสติ้ง" }, - { key: "tiktokListingId", labelTH: "TikTok ID", groupTH: "ช่องทาง/ลิสติ้ง" }, - - // กฎหมาย/ความสอดคล้อง - { key: "complianceStatus", labelTH: "Compliance", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => renderCompliance(v as ComplianceStatus) }, - { key: "thaiFdaNo", labelTH: "เลข อย.", groupTH: "กฎหมาย/ความสอดคล้อง" }, - { key: "hazardClass", labelTH: "Hazard", groupTH: "กฎหมาย/ความสอดคล้อง" }, - { key: "storageInstruction", labelTH: "คำแนะนำการเก็บ", groupTH: "กฎหมาย/ความสอดคล้อง" }, - { key: "shelfLifeMonths", labelTH: "อายุสินค้า(ด.)", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" }, - { key: "ageRestrictionMin", labelTH: "อายุขั้นต่ำ", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" }, - { key: "allergens", labelTH: "สารก่อภูมิแพ้", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => Array.isArray(v) && v.length ? v.join("|") : "-" }, - { key: "certificationsCount", labelTH: "จำนวนใบรับรอง", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" }, - { key: "msdsUrl", labelTH: "MSDS", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => v ? ( - - เปิด - - ) : "-" }, - - // R&D / สูตร - { key: "qaStage", labelTH: "QA Stage", groupTH: "R&D/สูตร" }, - { key: "formulationVersion", labelTH: "สูตรเวอร์ชัน", groupTH: "R&D/สูตร" }, - { key: "ingredientsCount", labelTH: "จำนวนส่วนผสม", groupTH: "R&D/สูตร", align: "right" }, - { key: "rdOwner", labelTH: "ผู้รับผิดชอบ R&D", groupTH: "R&D/สูตร" }, - - // บรรจุภัณฑ์ - { key: "packLevels", labelTH: "ระดับแพ็ก", groupTH: "บรรจุภัณฑ์", align: "right" }, - { key: "packPrimaryBarcode", labelTH: "บาร์โค้ดหน่วย", groupTH: "บรรจุภัณฑ์" }, - { key: "packCaseBarcode", labelTH: "บาร์โค้ดลัง", groupTH: "บรรจุภัณฑ์" }, - { key: "packNotes", labelTH: "บันทึกแพ็ก", groupTH: "บรรจุภัณฑ์" }, - - // ซัพพลาย/การผลิต - { key: "manufacturer", labelTH: "ผู้ผลิต", groupTH: "ซัพพลาย/การผลิต" }, - { key: "mfgCountry", labelTH: "ประเทศผลิต", groupTH: "ซัพพลาย/การผลิต" }, - { key: "mfgLeadTimeDays", labelTH: "Lead Time(วัน)", groupTH: "ซัพพลาย/การผลิต", align: "right" }, - { key: "moq", labelTH: "MOQ", groupTH: "ซัพพลาย/การผลิต", align: "right" }, - - // วงจรชีวิต - { key: "status", labelTH: "สถานะ", groupTH: "วงจรชีวิต", format: (v) => renderLifecycle(v as LifecycleStatus) }, - { key: "publishedAt", labelTH: "เผยแพร่เมื่อ", groupTH: "วงจรชีวิต", format: (v) => fmtDate(v as string | null | undefined) }, - { key: "createdAt", labelTH: "สร้างเมื่อ", groupTH: "วงจรชีวิต", format: (v) => fmtDate(v as string | null | undefined) }, - { key: "updatedAt", labelTH: "แก้ไขล่าสุด", groupTH: "วงจรชีวิต", format: (v) => fmtDate(v as string | null | undefined) }, - { key: "archivedAt", labelTH: "เก็บถาวรเมื่อ", groupTH: "วงจรชีวิต", format: (v) => fmtDate(v as string | null | undefined) }, - - // อื่น ๆ - { key: "tags", labelTH: "แท็ก", groupTH: "อื่น ๆ", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, - { key: "note", labelTH: "โน้ต", groupTH: "อื่น ๆ" }, -]; - -/* ======================== Visible/Groups ======================== */ -const DEFAULT_VISIBLE: ColumnKey[] = [ - "productCode","productName","variantName","sku","type", - "brand","category","status", - "complianceStatus","thaiFdaNo","listPrice", - "channelsPublished","imageCount","docsCount", -]; - -const GROUPS: { titleTH: string; keys: ColumnKey[] }[] = [ - { titleTH: "เอกลักษณ์ & โครงสร้าง", keys: ["id","productCode","parentProductId","type","sku","gtin","attributesSummary"] }, - { titleTH: "แบรนด์/หมวด", keys: ["brand","brandId","category","categoryPath","categoryId"] }, - { titleTH: "คอนเทนต์", keys: ["productName","variantName","descriptionShort","slug"] }, - { titleTH: "หน่วย & ขนาด", keys: ["uom","weightGrams","dimLcm","dimWcm","dimHcm","netVolumeMl"] }, - { titleTH: "ราคา/การตลาด", keys: ["currency","listPrice","compareAtPrice","priceListsCount"] }, - { titleTH: "สื่อ/เอกสาร/SEO", keys: ["imageCount","docsCount","seoTitle"] }, - { titleTH: "ช่องทาง/ลิสติ้ง", keys: ["channelsPublished","d2cPublished","shopeePublished","lazadaPublished","tiktokPublished","d2cUrl","shopeeListingId","lazadaListingId","tiktokListingId"] }, - { titleTH: "กฎหมาย/ความสอดคล้อง", keys: ["complianceStatus","thaiFdaNo","hazardClass","storageInstruction","shelfLifeMonths","ageRestrictionMin","allergens","certificationsCount","msdsUrl"] }, - { titleTH: "R&D/สูตร", keys: ["qaStage","formulationVersion","ingredientsCount","rdOwner"] }, - { titleTH: "บรรจุภัณฑ์", keys: ["packLevels","packPrimaryBarcode","packCaseBarcode","packNotes"] }, - { titleTH: "ซัพพลาย/การผลิต", keys: ["manufacturer","mfgCountry","mfgLeadTimeDays","moq"] }, - { titleTH: "วงจรชีวิต", keys: ["status","publishedAt","createdAt","updatedAt","archivedAt"] }, - { titleTH: "อื่น ๆ", keys: ["tags","note"] }, -]; - -/* ======================== Page ======================== */ -const LS_KEY = "products.visibleCols.v2"; - -export default function ProductsPage() { - const [q, setQ] = useState(""); - const [ptype, setPtype] = useState<"all" | ProductType>("all"); - const [lifecycle, setLifecycle] = useState<"all" | LifecycleStatus>("all"); - const [compliance, setCompliance] = useState<"all" | ComplianceStatus>("all"); - const [brand, setBrand] = useState<"all" | string>("all"); - const [category, setCategory] = useState<"all" | string>("all"); - const [channel, setChannel] = useState("all"); - - const [visibleKeys, setVisibleKeys] = useState(DEFAULT_VISIBLE); - const [openCustomize, setOpenCustomize] = useState(false); - const [tempKeys, setTempKeys] = useState(DEFAULT_VISIBLE); - - useEffect(() => { - try { - const raw = localStorage.getItem(LS_KEY); - if (raw) { - const parsed = JSON.parse(raw) as ColumnKey[]; - if (Array.isArray(parsed) && parsed.length) { - setVisibleKeys(parsed); - setTempKeys(parsed); - } - } - } catch { /* ignore */ } - }, []); - - const persistVisible = (keys: ColumnKey[]) => { - setVisibleKeys(keys); - try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ } - }; - - const brands = useMemo(() => Array.from(new Set(MOCK_PRODUCTS.map(p => p.brand))).sort(), []); - const categories = useMemo(() => Array.from(new Set(MOCK_PRODUCTS.map(p => p.category))).sort(), []); - - const channelFlags: Record, keyof Product> = { - d2c: "d2cPublished", - shopee: "shopeePublished", - lazada: "lazadaPublished", - tiktok: "tiktokPublished", - }; - - const rows = useMemo(() => { - let arr = [...MOCK_PRODUCTS]; - - if (ptype !== "all") arr = arr.filter(p => p.type === ptype); - if (lifecycle !== "all") arr = arr.filter(p => p.status === lifecycle); - if (compliance !== "all") arr = arr.filter(p => p.complianceStatus === compliance); - if (brand !== "all") arr = arr.filter(p => p.brand === brand); - if (category !== "all") arr = arr.filter(p => p.category === category); - - if (channel !== "all") { - if (channel === "none") { - arr = arr.filter(p => !p.d2cPublished && !p.shopeePublished && !p.lazadaPublished && !p.tiktokPublished); - } else { - const flagKey = channelFlags[channel]; - arr = arr.filter(p => Boolean(p[flagKey])); - } - } - - if (q.trim()) { - const s = q.trim().toLowerCase(); - arr = arr.filter((p) => - [ - p.productCode, p.productName, p.variantName, p.sku, p.gtin, - p.brand, p.category, p.categoryPath, p.slug, - p.thaiFdaNo, p.hazardClass, p.storageInstruction, p.channelsPublished, - p.shopeeListingId, p.lazadaListingId, p.tiktokListingId, p.d2cUrl, - p.rdOwner, p.manufacturer, p.note, p.attributesSummary, ...(p.tags ?? []) - ] - .filter((x): x is string => typeof x === "string") - .some((x) => x.toLowerCase().includes(s)), - ); - } - - arr.sort((a, b) => { - const tb = +new Date(b.updatedAt ?? b.createdAt); - const ta = +new Date(a.updatedAt ?? a.createdAt); - return tb - ta; - }); - - return arr; - }, [q, ptype, lifecycle, compliance, brand, category, channel]); - - const visibleCols: Column[] = useMemo(() => { - const map = new Map(ALL_COLS.map((c) => [c.key, c])); - return visibleKeys - .map((k) => map.get(k)) - .filter((c): c is Column => Boolean(c)); - }, [visibleKeys]); - - const topHeaderSegments = useMemo(() => { - type Seg = { groupTH: string; colSpan: number }; - const segs: Seg[] = []; - visibleCols.forEach((c, i) => { - const g = c.groupTH; - if (i === 0 || segs[segs.length - 1].groupTH !== g) segs.push({ groupTH: g, colSpan: 1 }); - else segs[segs.length - 1].colSpan += 1; - }); - return segs; - }, [visibleCols]); - - /* ---------- Filter helpers / styles ---------- */ - const Field = ({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) => ( -
- -
{children}
-
- ); - // ✅ โทนเดียวกับหน้าอื่น: ตัด h-10 ออก ใช้ py-2.5 และไม่ใส่ focus ring - const inputBase = - "w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 py-2.5 text-sm shadow-sm outline-none placeholder:text-neutral-400 focus:border-neutral-300"; - const selectBase = - "appearance-none w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 py-2.5 text-sm shadow-sm outline-none focus:border-neutral-300"; - - const activeFilters = [ - ...(q.trim() ? [{ key: "q", label: "ค้นหา", value: q.trim() }] : []), - ...(ptype !== "all" ? [{ key: "ptype", label: "ชนิด", value: TYPE_META[ptype].label }] : []), - ...(lifecycle !== "all" ? [{ key: "lifecycle", label: "สถานะ", value: LIFECYCLE_META[lifecycle].label }] : []), - ...(compliance !== "all" ? [{ key: "compliance", label: "Compliance", value: COMPLIANCE_META[compliance].label }] : []), - ...(brand !== "all" ? [{ key: "brand", label: "แบรนด์", value: brand }] : []), - ...(category !== "all" ? [{ key: "category", label: "หมวด", value: category }] : []), - ...(channel !== "all" ? [{ key: "channel", label: "ช่องทาง", value: channel === "none" ? "ยังไม่เผยแพร่" : channel.toUpperCase() }] : []), - ] as { key: string; label: string; value: string }[]; - - const clearChip = (key: string) => { - switch (key) { - case "q": setQ(""); break; - case "ptype": setPtype("all"); break; - case "lifecycle": setLifecycle("all"); break; - case "compliance": setCompliance("all"); break; - case "brand": setBrand("all"); break; - case "category": setCategory("all"); break; - case "channel": setChannel("all"); break; - } - }; - - const clearAllFilters = () => { - setQ(""); - setPtype("all"); - setLifecycle("all"); - setCompliance("all"); - setBrand("all"); - setCategory("all"); - setChannel("all"); - }; - - return ( - <> - {/* กว้างหน้า “นิ่ง” */} - - -
- {/* Header */} -
-
-

สินค้า (Product Master)

-

- มุมมองเต็ม • แสดง {visibleCols.length} ฟิลด์ • ทั้งหมด {rows.length} รายการ -

-
-
- - - -
-
- - {/* Filters */} -
-
- {/* Search */} - -
- - setQ(e.target.value)} - className={`${inputBase} pl-9`} - /> -
-
- - {/* Selects */} - - - - - - - - - - - - - - - - - - - - - - - -
- - {/* Chips ของตัวกรองที่ใช้งาน */} - {activeFilters.length > 0 && ( -
- {activeFilters.map((c) => ( - - {c.label}: - {c.value} - - - ))} - -
- )} -
- - {/* Table */} -
-
-
- - - {/* Group header (เส้น/พื้นที่ชัด) */} - - {topHeaderSegments.map((seg, idx) => ( - - ))} - - {/* Column labels */} - - {visibleCols.map((c) => ( - - ))} - - - - - {rows.map((p, rowIdx) => { - const numericKeys: readonly ColumnKey[] = [ - "weightGrams","dimLcm","dimWcm","dimHcm","netVolumeMl", - "shelfLifeMonths","ageRestrictionMin","ingredientsCount","priceListsCount", - "mfgLeadTimeDays","moq","packLevels" - ]; - return ( - - {visibleCols.map((c) => { - const raw = getProp(p, c.key); - const content: ReactNode = c.format - ? c.format(raw, p) - : c.key === "listPrice" || c.key === "compareAtPrice" - ? THB(raw as number | null | undefined) - : numericKeys.includes(c.key) - ? num(raw as number | null | undefined) - : defaultCell(raw); - - const align = c.align === "right" ? "text-right" : ""; - return ( - - ); - })} - - ); - })} - -
- {seg.groupTH} -
- {c.labelTH} -
- {c.key === "productCode" ? ( - - {content} - - ) : c.key === "tags" && Array.isArray(p.tags) ? ( -
- {p.tags.length ? p.tags.map((t) => ( - {t} - )) : -} -
- ) : ( - content - )} -
-
-
-
- - {/* Customize dialog */} - {openCustomize && ( -
setOpenCustomize(false)}> -
e.stopPropagation()}> -
-

ปรับแต่งตาราง

- -
- -
-
- - - - เลือก {tempKeys.length} คอลัมน์ -
- -
- {GROUPS.map((g) => ( -
-
- {g.titleTH} -
- - -
-
-
- {g.keys.map((k) => { - const col = ALL_COLS.find((c) => c.key === k)!; - const checked = tempKeys.includes(k); - return ( - - ); - })} -
-
- ))} -
-
- -
-
ระบบจะจำคอลัมน์ที่คุณเลือกไว้ในเบราว์เซอร์นี้
-
- - -
-
-
-
- )} -
- - ); -} +export default async function ProductPage() { + return ; +} \ No newline at end of file diff --git a/src/app/(public)/redeem/[transactionId]/page.tsx b/src/app/(public)/redeem/[transactionId]/page.tsx index 6c8ab56..2cb628d 100644 --- a/src/app/(public)/redeem/[transactionId]/page.tsx +++ b/src/app/(public)/redeem/[transactionId]/page.tsx @@ -1,27 +1,66 @@ -// File: src/app/(public)/redeem/[transactionId]/page.tsx "use client"; -import { useMemo, useRef, useState } from "react"; -import { useParams, useSearchParams } from "next/navigation"; +import {useMemo, useRef, useState} from "react"; +import {useParams, useSearchParams} from "next/navigation"; import BrandLogo from "@/app/component/common/BrandLogo"; -import { genIdempotencyKey } from "@/lib/idempotency"; -import { resolveTenantKeyFromHost } from "@/lib/tenant"; -import { onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode } from "@/lib/phone"; -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; + consentRequired: boolean; + contactId?: string; + loyaltyAccountId?: string; + pointsBalance?: number; +}; +type CombinedResult = { + status?: StatusResponse; + onboarding?: OnboardResponse; + redeem?: RedeemResponse; +}; + +type ApiErrorBody = { code?: string; message?: string }; + +/* ===== Page ===== */ export default function RedeemPage() { const params = useParams<{ transactionId: string }>(); const search = useSearchParams(); const transactionId = params?.transactionId ?? search?.get("transactionId") ?? ""; const [phoneDigits, setPhoneDigits] = useState(""); + const [agreeTerms, setAgreeTerms] = useState(false); + const [marketingOptIn, setMarketingOptIn] = useState(true); + const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); - const idemRef = useRef(null); + // UI branching + const [showConsent, setShowConsent] = useState(false); + const [consentReason, setConsentReason] = useState(null); // e.g. "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" + // 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"; @@ -32,10 +71,13 @@ export default function RedeemPage() { }, [phoneDigits]); 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 = @@ -44,11 +86,13 @@ export default function RedeemPage() { : null; if (data && !/^[0-9๐-๙]+$/.test(data)) e.preventDefault(); } + function handleKeyDown(e: React.KeyboardEvent) { const allow = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight", "Home", "End"]; if (allow.includes(e.key) || /^[0-9]$/.test(e.key)) return; e.preventDefault(); } + function handlePaste(e: React.ClipboardEvent) { e.preventDefault(); const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? ""); @@ -56,69 +100,210 @@ export default function RedeemPage() { setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10)); } + async function parseMaybeJson(res: Response): Promise<{ + ok: boolean; + json?: any; + text?: string; + 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}}; + } catch { + if (res.ok) return {ok: true, text: txt}; + return {ok: false, err: {message: txt}}; + } + } + + async function getStatus(tenantKey: string): Promise { + 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!, + }, + cache: "no-store", + }); + const parsed = await parseMaybeJson(res); + if (!parsed.ok) { + const code = parsed.err?.code ?? "STATUS_FAILED"; + throw new Error(parsed.err?.message || code); + } + return parsed.json as StatusResponse; + } + + async function onboard(tenantKey: string): Promise { + idemOnboardRef.current = genIdempotencyKey(); + const payload: OnboardRequest = { + contact: {phone: phoneDigits}, + consent: agreeTerms ? {termsAccepted: true, marketingOptIn} : undefined, + metadata: { + source: "qr-landing", + consentAt: agreeTerms ? new Date().toISOString() : undefined, + }, + }; + const res = await fetch(`/api/public/loyalty/onboard`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Tenant-Key": tenantKey, + "Idempotency-Key": idemOnboardRef.current!, + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + const parsed = await parseMaybeJson(res); + if (!parsed.ok) { + const code = parsed.err?.code ?? "ONBOARD_FAILED"; + throw new Error(parsed.err?.message || code); + } + return parsed.json as OnboardResponse; + } + + async function redeem(tenantKey: string): Promise { + idemRedeemRef.current = genIdempotencyKey(); + const payload: RedeemRequest = { + transactionId, + 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!, + }, + body: JSON.stringify(payload), + cache: "no-store", + }); + const parsed = await parseMaybeJson(res); + if (!parsed.ok) { + const code = parsed.err?.code ?? "REDEEM_FAILED"; + const msg = parsed.err?.message || code; + + // Signal-driven branching + if (code === "CONSENT_REQUIRED" || code === "CONTACT_NOT_FOUND" || code === "LOYALTY_NOT_FOUND") { + setShowConsent(true); + setConsentReason(code); + } + throw new Error(msg); + } + return parsed.json as RedeemResponse; + } + + /* ===== Main submit flow ===== */ async function onSubmit(e: React.FormEvent) { e.preventDefault(); - if (!transactionId || !phoneValid || loading) return; + if (!phoneValid) return; setLoading(true); setError(null); setMessage(null); setResult(null); + const tenantKey = resolveTenantKeyFromHost(); + try { - const tenantKey = resolveTenantKeyFromHost(); - idemRef.current = genIdempotencyKey(); + // Case A: มี transaction → พยายาม Redeem ก่อน แล้วค่อยถาม consent ถ้าจำเป็น + if (transactionId) { + try { + const redeemRes = await redeem(tenantKey); + setResult({redeem: redeemRes}); + setMessage("สะสมคะแนนสำเร็จ"); + setShowConsent(false); + setConsentReason(null); + return; + } catch (exFirst: any) { + // ถ้าล้มเหลวเพราะต้องยินยอมหรือไม่มีบัญชี โชว์ consent แล้วรอให้ผู้ใช้ติ๊ก จากนั้น onboard และ redeem ซ้ำ + if (!showConsent) { + // โชว์ consent แล้วให้ผู้ใช้กดปุ่มเดิมอีกครั้งหลังติ๊ก + setLoading(false); + return; + } + // ผู้ใช้ติ๊กแล้ว → สร้าง/อัปเดต consent แล้ว Redeem ซ้ำ + const onboarding = await onboard(tenantKey); + const redeemRes = await redeem(tenantKey); + setResult({onboarding, redeem: redeemRes}); + setMessage(onboarding.isNew ? "สมัครสมาชิกและสะสมคะแนนสำเร็จ" : "ยืนยันสมาชิกและสะสมคะแนนสำเร็จ"); + setShowConsent(false); + setConsentReason(null); + return; + } + } - const payload: RedeemRequest = { - transactionId, - contact: { phone: phoneDigits }, - metadata: { source: "qr-landing" }, - }; + // Case B: ไม่มี transaction → ตรวจสถานะก่อน (มีบัญชี/ยินยอมแล้วหรือยัง) + const status = await getStatus(tenantKey); + setResult({status}); - const res = await fetch(`/api/public/redeem`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Tenant-Key": tenantKey, - "Idempotency-Key": idemRef.current!, - }, - body: JSON.stringify(payload), - cache: "no-store", - }); + if (!status.exists || !status.hasLoyalty) { + // ยังไม่มีบัญชี → ต้อง onboard (ต้องขอ consent) + setShowConsent(true); + setConsentReason("CONTACT_NOT_FOUND"); + if (!agreeTerms) { + setLoading(false); + return; + } + const onboarding = await onboard(tenantKey); + setResult({status, onboarding}); + setMessage(onboarding.isNew ? "สมัครสมาชิกสำเร็จ" : "ยืนยันสมาชิกสำเร็จ"); + setShowConsent(false); + setConsentReason(null); + return; + } - if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`); + if (status.consentRequired) { + // มีบัญชีแล้วแต่ยังไม่เคยยินยอม/ consent หมดอายุ + setShowConsent(true); + setConsentReason("CONSENT_REQUIRED"); + if (!agreeTerms) { + setLoading(false); + return; + } + const onboarding = await onboard(tenantKey); + setResult({status, onboarding}); + setMessage("อัปเดตการยินยอมสำเร็จ"); + setShowConsent(false); + setConsentReason(null); + return; + } - const data: RedeemResponse = await res.json(); - setResult(data); - setMessage("สะสมคะแนนสำเร็จ"); + // มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลลัพธ์สั้นๆ + setMessage("เข้าสู่ระบบสมาชิกสำเร็จ"); + setShowConsent(false); + setConsentReason(null); } catch (ex: unknown) { const err = ex as Error; - setError(err.message ?? "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้"); + setError(err.message ?? "เกิดข้อผิดพลาด"); } finally { setLoading(false); - idemRef.current = null; + idemStatusRef.current = null; + idemOnboardRef.current = null; + idemRedeemRef.current = null; } } + /* ===== UI ===== */ return (
- {/* Brand */}
- +
- {/* Heading */} -

Redeem Points

+

Redeem / Join Loyalty

Transaction: {transactionId || "—"}

- {/* Card */}
{!transactionId && ( -
- ไม่พบ TransactionId ใน URL โปรดสแกน QR ใหม่ +
+ ไม่มี Transaction ก็สามารถสมัครสมาชิกได้จากหน้านี้
)} @@ -146,7 +331,7 @@ export default function RedeemPage() { type="text" />
- รองรับเฉพาะเบอร์โทรศัพท์มือถือ + รองรับเฉพาะเบอร์โทรศัพท์มือถือไทย 10 หลัก
{phoneError && (
@@ -155,51 +340,145 @@ export default function RedeemPage() { )}
+ {showConsent && ( +
+
+ {consentReason === "CONTACT_NOT_FOUND" + ? "สมัครสมาชิกและยินยอมตามข้อตกลง" + : "อัปเดตการยินยอมตามข้อตกลง"} +
+ + + {!agreeTerms && ( +
โปรดยอมรับข้อตกลงก่อนดำเนินการต่อ
+ )} +
+ )} + {message && ( -
- สะสมคะแนนสำเร็จ +
+ {message}
)} {error && (
- สะสมคะแนนไม่สำเร็จ + {error}
)} {result && ( -
-
รายละเอียด
-
- {"balance" in result && ( -
- ยอดคงเหลือใหม่: {result.balance} +
+ {result.status && ( +
+
สถานะสมาชิก
+
+
มีบัญชี: {String(result.status.exists)}
+
มี Loyalty: {String(result.status.hasLoyalty)}
+
ต้องยินยอม: {String(result.status.consentRequired)}
+ {typeof result.status.pointsBalance === "number" && ( +
+ คะแนนคงเหลือ: {result.status.pointsBalance} +
+ )}
- )} - {"voucherCode" in result && ( -
- รหัสคูปอง: {result.voucherCode} +
+ )} + + {result.onboarding && ( +
+
สมาชิก
+
+
+ Contact: {result.onboarding.contactId} +
+
+ Loyalty: {result.onboarding.loyaltyAccountId} +
+ {"pointsBalance" in result.onboarding && typeof result.onboarding.pointsBalance === "number" && ( +
+ คะแนนคงเหลือ: {result.onboarding.pointsBalance} +
+ )} +
สถานะ: {result.onboarding.isNew ? "สร้างบัญชีใหม่" : "ยืนยันสมาชิกเดิม"}
- )} - {"ledgerEntryId" in result && ( -
- Ledger: {result.ledgerEntryId} +
+ )} + + {result.redeem && ( +
+
การแลก/สะสม
+
+ {"balance" in result.redeem && ( +
+ ยอดคงเหลือใหม่: {result.redeem.balance} +
+ )} + {"voucherCode" in result.redeem && ( +
+ รหัสคูปอง: {result.redeem.voucherCode} +
+ )} + {"ledgerEntryId" in result.redeem && ( +
+ Ledger: {result.redeem.ledgerEntryId} +
+ )} + {"redeemed" in result.redeem && +
สถานะ: {String(result.redeem.redeemed)}
}
- )} - {"redeemed" in result &&
สถานะ: {String(result.redeemed)}
} -
+
+ )}
)}
); -} +} \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 60aeb9e..c9267a1 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { API_BASE, TENANT_KEY, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config"; +import { API_BASE, TENANT_KEY, COOKIE_SECURE, COOKIE_SAMESITE } from "@/config/env"; import type { UpstreamLoginResponse } from "@/types/auth"; const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true"; diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index 2341dd1..d4cc690 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config"; -import { resolveTenantFromRequest } from "@/lib/server-tenant"; +import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/config/env"; +import { resolveTenantFromRequest } from "@/server/tenant/service"; import type { UpstreamRefreshResponse } from "@/types/auth"; const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true"; diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index 442756b..6a16836 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { API_BASE } from "@/lib/config"; -import { resolveTenantFromRequest } from "@/lib/server-tenant"; +import { API_BASE } from "@/config/env"; +import { resolveTenantFromRequest } from "@/server/tenant/service"; type Ctx = { params: Promise<{ path: string[] }> }; diff --git a/src/app/component/layout/AppShell.tsx b/src/app/component/layout/AppShell.tsx index bdf9fdc..13b2b15 100644 --- a/src/app/component/layout/AppShell.tsx +++ b/src/app/component/layout/AppShell.tsx @@ -1,81 +1,137 @@ -// src/app/component/layout/AppShell.tsx "use client"; -import { ReactNode, useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import type { UserSession } from "@/types/auth"; -import type { NavItem as ServerNavItem, IconKey } from "@/lib/nav"; +import {ReactNode, useEffect, useMemo, useState} from "react"; +import NextLink from "next/link"; +import {usePathname} from "next/navigation"; +import type {UserSession} from "@/types/auth"; +import type { + NavItem as ServerNavItem, + NavTreeItem as ServerNavTreeItem, + IconKey, +} from "@/modules/navigation/nav"; -import type { LucideIcon } from "lucide-react"; +import type {LucideIcon} from "lucide-react"; import { - // groups Home, Settings, Megaphone, Warehouse as WarehouseIcon, Wrench, ShoppingCart, Bell, BarChart3, CircleDollarSign, - // pages/common LayoutDashboard, Users, UserRound, PhoneCall, MessageSquare, Headset, BookOpen, Newspaper, Bot, - // sales & catalog Package, Boxes, Tag, FileText, Repeat, - // services - CalendarClock, Route, Calendar, UserCog, ClipboardList, Timer, FileSignature, Box, BadgeCheck, - // procurement & warehouse + Route, Calendar, UserCog, ClipboardList, Timer, FileSignature, Box, BadgeCheck, Store, Inbox, Truck, ClipboardCheck, - // marketing BadgePercent, Gift, - // finance - Receipt, CreditCard, Calculator, PiggyBank, ListChecks, - // admin/integrations + Receipt, CreditCard, PiggyBank, ListChecks, PlugZap, Webhook, KeyRound, FileSearch, Building2, Activity, CodeSquare, Smile, - // chrome - Menu, PanelLeftOpen, PanelLeftClose, + Menu, PanelLeftOpen, PanelLeftClose, ChevronDown, ChevronRight, + ShieldCheck, Hourglass, Eraser, Send, AlertTriangle, RotateCcw, ListOrdered, Link as LinkIcon2, Medal, } from "lucide-react"; type Props = { user: UserSession; nav: ServerNavItem[]; - children: ReactNode; // <<<< เพิ่ม children ลงใน Props + tree?: ServerNavTreeItem[]; + children: ReactNode; }; -// map string key -> actual Lucide icon const ICONS: Record = { - // groups - home: Home, sales: ShoppingCart, services: Wrench, procurement: Store, warehouse: WarehouseIcon, - support: Headset, marketing: Megaphone, finance: CircleDollarSign, analytics: BarChart3, admin: Settings, - // common - dashboard: LayoutDashboard, myTasks: ClipboardCheck, notifications: Bell, - // sales & catalog - customers: Users, contacts: UserRound, leads: PhoneCall, orders: ShoppingCart, quotes: FileText, - subscriptions: Repeat, products: Package, bundles: Boxes, pricing: Tag, returns: Repeat, - // services - serviceCatalog: Tag, servicePricing: Tag, serviceOrders: Wrench, dispatchBoard: Route, - schedule: Calendar, resources: UserCog, projects: ClipboardList, timesheets: Timer, - contracts: FileSignature, installedBase: Box, warrantyClaims: BadgeCheck, - // procurement & warehouse - suppliers: Store, purchaseOrders: ClipboardCheck, grn: Inbox, - inventory: Boxes, receiving: Inbox, picking: ClipboardList, packing: Package, shipments: Truck, - // support & knowledge - tickets: MessageSquare, sla: Timer, csat: Smile, aiChat: Bot, knowledgeBase: BookOpen, - // marketing - campaigns: Megaphone, coupons: BadgePercent, loyalty: Gift, news: Newspaper, pages: FileText, - // finance - invoices: Receipt, payments: CreditCard, refunds: Repeat, generalLedger: PiggyBank, reconciliation: ListChecks, - // analytics - reports: BarChart3, dashboards: LayoutDashboard, - // admin & system - users: Users, rolesPermissions: UserCog, tenants: Building2, settings: Settings, - flashExpress: Truck, tiktokShop: PlugZap, psp: CreditCard, webhooks: Webhook, apiKeys: KeyRound, - compliance: CircleDollarSign, documents: FileText, eSign: FileSignature, auditTrail: FileSearch, - systemHealth: Activity, logs: ListChecks, developerSandbox: CodeSquare, - // misc - kms: BookOpen, chat: Bot, + home: Home, + sales: ShoppingCart, + services: Wrench, + procurement: Store, + warehouse: WarehouseIcon, + support: Headset, + marketing: Megaphone, + finance: CircleDollarSign, + analytics: BarChart3, + admin: Settings, + dashboard: LayoutDashboard, + myTasks: ClipboardCheck, + notifications: Bell, + customers: Users, + contacts: UserRound, + leads: PhoneCall, + orders: ShoppingCart, + quotes: FileText, + subscriptions: Repeat, + products: Package, + bundles: Boxes, + pricing: Tag, + returns: Repeat, + serviceCatalog: Tag, + servicePricing: Tag, + serviceOrders: Wrench, + dispatchBoard: Route, + schedule: Calendar, + resources: UserCog, + projects: ClipboardList, + timesheets: Timer, + contracts: FileSignature, + installedBase: Box, + warrantyClaims: BadgeCheck, + suppliers: Store, + purchaseOrders: ClipboardCheck, + grn: Inbox, + inventory: Boxes, + receiving: Inbox, + picking: ClipboardList, + packing: Package, + shipments: Truck, + tickets: MessageSquare, + sla: Timer, + csat: Smile, + aiChat: Bot, + knowledgeBase: BookOpen, + campaigns: Megaphone, + coupons: BadgePercent, + loyalty: Gift, + news: Newspaper, + pages: FileText, + invoices: Receipt, + payments: CreditCard, + refunds: Repeat, + generalLedger: PiggyBank, + reconciliation: ListChecks, + reports: BarChart3, + dashboards: LayoutDashboard, + users: Users, + rolesPermissions: UserCog, + tenants: Building2, + settings: Settings, + flashExpress: Truck, + tiktokShop: PlugZap, + psp: CreditCard, + webhooks: Webhook, + apiKeys: KeyRound, + compliance: ShieldCheck, + documents: FileText, + eSign: FileSignature, + auditTrail: FileSearch, + systemHealth: Activity, + logs: ListChecks, + developerSandbox: CodeSquare, + loyaltyAccounts: Users, + redeem: Gift, + loyaltyLedger: ListOrdered, + rewards: Gift, + tiers: Medal, + rules: ListChecks, + linkAccounts: LinkIcon2, + consents: ShieldCheck, + retention: Hourglass, + anonymize: Eraser, + outbox: Send, + inbox: Inbox, + deadLetters: AlertTriangle, + idempotency: RotateCcw, + kms: BookOpen, + chat: Bot, }; -export default function AppShell({ user, nav, children }: Props) { +export default function AppShell({user, nav, tree, children}: Props) { const [sidebarOpen, setSidebarOpen] = useState(true); const [mobileOpen, setMobileOpen] = useState(false); - const pathname = usePathname(); + const [expanded, setExpanded] = useState>({}); const initials = useMemo(() => { const email = user?.email ?? ""; @@ -85,41 +141,204 @@ export default function AppShell({ user, nav, children }: Props) { const year = new Date().getFullYear(); - const isActive = (item: ServerNavItem): boolean => { - if (item.match) { - try { return new RegExp(item.match).test(pathname); } - catch { return pathname.startsWith(item.match); } + const testMatch = (pattern: string, path: string) => { + try { + return new RegExp(pattern).test(path); + } catch { + return path.startsWith(pattern); } - return pathname === item.href; }; - useEffect(() => { setMobileOpen(false); }, [pathname]); + const isActiveFlat = (item: ServerNavItem): boolean => + item.match ? testMatch(item.match, pathname) : (pathname === item.href || pathname.startsWith(item.href + "/")); + + const hasActiveDescendant = (nodes?: ServerNavTreeItem[]): boolean => { + if (!nodes) return false; + for (const n of nodes) { + if (n.href && (pathname === n.href || pathname.startsWith(n.href + "/"))) return true; + if (n.children?.length && hasActiveDescendant(n.children)) return true; + } + return false; + }; + + useEffect(() => { + if (!tree?.length) return; + const next: Record = {}; + const walk = (nodes: ServerNavTreeItem[], path: string[] = []) => { + for (const n of nodes) { + const key = [...path, n.label].join(">"); + const childActive = hasActiveDescendant(n.children); + const selfActive = n.href ? pathname === n.href || pathname.startsWith(n.href + "/") : false; + if (n.children?.length) { + next[key] = childActive || selfActive || expanded[key] || false; + walk(n.children, [...path, n.label]); + } + } + }; + walk(tree); + setExpanded((prev) => ({...prev, ...next})); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname, tree?.length]); + + useEffect(() => { + setMobileOpen(false); + }, [pathname]); useEffect(() => { const el = document.documentElement; if (mobileOpen) { const prev = el.style.overflow; el.style.overflow = "hidden"; - return () => { el.style.overflow = prev; }; + return () => { + el.style.overflow = prev; + }; } }, [mobileOpen]); + /* ---------- Styles: fixed alignment (no shift) ---------- */ + // ⬇⬇⬇ เพิ่ม w-full ที่นี่ ให้ปุ่ม/กรุ๊ปเต็มกรอบ + const itemBase = + "relative group w-full flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm leading-none transition-all select-none"; + const itemStates = (active: boolean) => + active + ? "bg-neutral-900 text-white shadow-[0_8px_24px_-16px_rgba(0,0,0,0.45)] ring-1 ring-white/10" + : "text-neutral-700 hover:bg-white/70 hover:shadow-[0_10px_30px_-18px_rgba(0,0,0,0.35)] hover:ring-1 hover:ring-black/5"; + + const iconBase = "grid place-items-center rounded-lg h-8 w-8 shrink-0 transition-colors"; + const iconStates = (active: boolean) => + active ? "bg-white/15 text-white" : "text-neutral-500 group-hover:text-neutral-800"; + + const indent = (open: boolean, depth: number) => ({paddingLeft: open ? 14 + depth * 14 : 12}); + + const Accent = ({active}: { active: boolean }) => ( + + ); + + const SidebarNavFlat = () => ( + + ); + + const SidebarNavTree = ({nodes, depth = 0, path = [] as string[]}: { + nodes: ServerNavTreeItem[]; + depth?: number; + path?: string[] + }) => { + return ( +
    + {nodes.map((n) => { + const key = [...path, n.label].join(">"); + const Icon = n.icon ? ICONS[n.icon] : undefined; + const hasChildren = !!n.children?.length; + const groupActive = hasChildren ? hasActiveDescendant(n.children) : false; + const selfActive = n.href ? pathname === n.href || pathname.startsWith(n.href + "/") : false; + const active = selfActive || groupActive; + const isOpen = hasChildren ? !!expanded[key] : false; + + if (hasChildren) { + return ( +
  • + + {isOpen && ( +
    + +
    + )} +
  • + ); + } + + if (!n.href) return null; + return ( +
  • + + + {Icon ? : + } + + {sidebarOpen && {n.label}} + + +
  • + ); + })} +
+ ); + }; + return ( -
- {/* Sidebar (desktop) */} -