From 7fb60ba0f5225361c8b5f0601505fabb5029a87d Mon Sep 17 00:00:00 2001 From: Thanakarn Klangkasame <77600906+Simulationable@users.noreply.github.com> Date: Sun, 5 Oct 2025 19:17:42 +0700 Subject: [PATCH] Add Login and Dashboard --- package-lock.json | 153 +++++- package.json | 15 +- src/app/(protected)/dashboard/page.tsx | 495 ++++++++++++++++++ src/app/(protected)/layout.tsx | 51 ++ src/app/(public)/layout.tsx | 3 + src/app/(public)/login/page.tsx | 165 ++++++ .../(public)/redeem/[transactionId]/page.tsx | 182 ++----- src/app/api/auth/login/route.ts | 145 +++++ src/app/api/auth/refresh/route.ts | 86 +++ src/app/api/crm/redeem/route.ts | 47 ++ src/app/api/proxy/[...path]/route.ts | 53 ++ src/app/component/layout/AppShell.tsx | 160 ++++++ src/app/page.tsx | 105 +--- src/app/providers.tsx | 15 + src/lib/config.ts | 6 + src/lib/idempotency.ts | 4 + src/lib/nav.ts | 16 + src/lib/phone.ts | 17 + src/lib/server-auth.ts | 166 ++++++ src/lib/server-tenant.ts | 47 ++ src/lib/tenant.ts | 9 + src/types/auth/index.ts | 2 + src/types/auth/upstream.ts | 19 + src/types/auth/user.ts | 18 + src/types/crm/index.ts | 1 + src/types/crm/loyalty.ts | 13 + 26 files changed, 1750 insertions(+), 243 deletions(-) create mode 100644 src/app/(protected)/dashboard/page.tsx create mode 100644 src/app/(protected)/layout.tsx create mode 100644 src/app/(public)/layout.tsx create mode 100644 src/app/(public)/login/page.tsx create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/refresh/route.ts create mode 100644 src/app/api/crm/redeem/route.ts create mode 100644 src/app/api/proxy/[...path]/route.ts create mode 100644 src/app/component/layout/AppShell.tsx create mode 100644 src/app/providers.tsx create mode 100644 src/lib/config.ts create mode 100644 src/lib/idempotency.ts create mode 100644 src/lib/nav.ts create mode 100644 src/lib/phone.ts create mode 100644 src/lib/server-auth.ts create mode 100644 src/lib/server-tenant.ts create mode 100644 src/lib/tenant.ts create mode 100644 src/types/auth/index.ts create mode 100644 src/types/auth/upstream.ts create mode 100644 src/types/auth/user.ts create mode 100644 src/types/crm/index.ts create mode 100644 src/types/crm/loyalty.ts diff --git a/package-lock.json b/package-lock.json index c7e19a1..630df0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "amrez-eop-app", "version": "0.1.0", "dependencies": { + "@types/jsonwebtoken": "^9.0.10", + "jsonwebtoken": "^9.0.2", "next": "15.5.4", + "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0" }, @@ -1290,11 +1293,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.18.tgz", "integrity": "sha512-KeYVbfnbsBCyKG8e3gmUqAfyZNcoj/qpEbHRkQkfZdKOBrU7QQ+BsTdfqLSWX9/m1ytYreMhpKvp+EZi3UFYAg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2204,6 +2222,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2526,6 +2550,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4141,6 +4174,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -4157,6 +4212,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4456,6 +4532,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4463,6 +4575,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4570,7 +4688,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4666,6 +4783,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5204,6 +5331,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5249,7 +5396,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5923,7 +6069,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index fd11d55..377c988 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,22 @@ "lint": "eslint" }, "dependencies": { + "@types/jsonwebtoken": "^9.0.10", + "jsonwebtoken": "^9.0.2", + "next": "15.5.4", + "next-themes": "^0.4.6", "react": "19.1.0", - "react-dom": "19.1.0", - "next": "15.5.4" + "react-dom": "19.1.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.5.4", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx new file mode 100644 index 0000000..e12978b --- /dev/null +++ b/src/app/(protected)/dashboard/page.tsx @@ -0,0 +1,495 @@ +// File: src/app/(protected)/dashboard/page.tsx +"use client"; + +/** + * EOP Dashboard — Commerce + Marketplace + Chat + IoT + AI Orchestration + * - สรุปภาพรวมยอดขาย (GMV/Orders/AOV/Refunds) + * - Channel Mix (Marketplace / D2C / Social/Chat) + * - Best Sellers & Low Stock + * - Live Ops: Fulfillment SLA, IoT Device Health + * - AI Flows: ล่าสุด/สถานะรัน, Queue/Latency + * - Alerts/Tasks + * + * NOTE: ตอนนี้ใช้ mock data เพื่อโชว์ design/ UX. + * เชื่อม API ได้โดยแทนที่ hooks ด้านล่าง (fetch/*) ให้ดึงจริงจาก proxy ของคุณ + */ + +import { useMemo, useState } from "react"; + +/* =========================== + Types (ภายในไฟล์) +=========================== */ +type Range = "today" | "7d" | "30d"; +type Channel = "all" | "shopee" | "lazada" | "tiktok" | "d2c" | "chat"; +type Kpi = { label: string; value: number | string; delta?: number; hint?: string; suffix?: string }; +type MixItem = { label: string; value: number; key: Channel }; +type Sku = { sku: string; name: string; units: number; gmv: number; stock?: number; thumbnail?: string }; +type Fulfillment = { metric: string; value: string; delta?: number; hint?: string }; +type Device = { id: string; site: string; role: "scanner" | "labeler" | "sorter" | "gateway"; status: "ok" | "warn" | "down"; lastSeen: string }; +type Flow = { id: string; name: string; runsToday: number; successRate: number; avgLatencyMs: number; lastRun: string; status: "ok" | "warn" | "error" }; +type Alert = { id: string; title: string; detail: string; when: string; level: "info" | "warn" | "error" }; + +/* =========================== + Mock Data +=========================== */ +const KPI_BY_RANGE: Record = { + today: [ + { label: "GMV", value: 186_420, delta: +6.8, suffix: "฿", hint: "รวมทุกช่องทาง" }, + { label: "Orders", value: 942, delta: +4.3 }, + { label: "AOV", value: 198, delta: +2.1, suffix: "฿" }, + { label: "Refunds", value: 1.2, delta: -0.2, suffix: "%", hint: "อัตราคืนสินค้า" }, + ], + "7d": [ + { label: "GMV", value: 1_126_500, delta: +8.9, suffix: "฿" }, + { label: "Orders", value: 5_814, delta: +5.1 }, + { label: "AOV", value: 194, delta: +1.4, suffix: "฿" }, + { label: "Refunds", value: 1.6, delta: -0.1, suffix: "%" }, + ], + "30d": [ + { label: "GMV", value: 4_812_900, delta: +12.3, suffix: "฿" }, + { label: "Orders", value: 24_310, delta: +7.7 }, + { label: "AOV", value: 198, delta: +2.0, suffix: "฿" }, + { label: "Refunds", value: 1.4, delta: -0.4, suffix: "%" }, + ], +}; + +const MIX_ALL: MixItem[] = [ + { label: "Shopee", value: 34, key: "shopee" }, + { label: "Lazada", value: 28, key: "lazada" }, + { label: "TikTok", value: 16, key: "tiktok" }, + { label: "Direct (D2C)", value: 12, key: "d2c" }, + { label: "Social/Chat", value: 10, key: "chat" }, +]; + +const BEST_SELLERS: Sku[] = [ + { sku: "CDIOR-50ML", name: "Dior Lip Glow 001 Pink", units: 382, gmv: 145_000, stock: 128 }, + { sku: "CDIOR-999", name: "Dior Rouge 999 Velvet", units: 274, gmv: 118_500, stock: 42 }, + { sku: "CDIOR-FOUND", name: "Forever Skin Glow 1N", units: 190, gmv: 86_200, stock: 18 }, + { sku: "CDIOR-MASC", name: "Diorshow Iconic Overcurl", units: 156, gmv: 62_700, stock: 9 }, + { sku: "CDIOR-SET", name: "Holiday Gift Set 2025", units: 120, gmv: 96_000, stock: 6 }, +]; + +const LOW_STOCK: Sku[] = [ + { sku: "CDIOR-999", name: "Dior Rouge 999 Velvet", units: 274, gmv: 118_500, stock: 42 }, + { sku: "CDIOR-FOUND", name: "Forever Skin Glow 1N", units: 190, gmv: 86_200, stock: 18 }, + { sku: "CDIOR-MASC", name: "Diorshow Iconic Overcurl", units: 156, gmv: 62_700, stock: 9 }, + { sku: "CDIOR-SET", name: "Holiday Gift Set 2025", units: 120, gmv: 96_000, stock: 6 }, +]; + +const SLA: Fulfillment[] = [ + { metric: "SLA Ship-on-time", value: "97.2%", delta: +0.4, hint: "ภายใน 24ชม." }, + { metric: "Picked in 2h", value: "92.5%", delta: -1.1 }, + { metric: "Packaging Defects", value: "0.7%", delta: -0.2 }, + { metric: "Delivery < 48h", value: "88.9%", delta: +2.8 }, +]; + +const DEVICES: Device[] = [ + { id: "GW-01", site: "BKK-DC", role: "gateway", status: "ok", lastSeen: "just now" }, + { id: "SC-12", site: "BKK-DC", role: "scanner", status: "ok", lastSeen: "12s ago" }, + { id: "LB-03", site: "BKK-DC", role: "labeler", status: "warn", lastSeen: "1m ago" }, + { id: "ST-07", site: "BKK-DC", role: "sorter", status: "ok", lastSeen: "5s ago" }, + { id: "SC-02", site: "CNX-HUB", role: "scanner", status: "down", lastSeen: "14m ago" }, +]; + +const FLOWS: Flow[] = [ + { id: "F-CHAT-01", name: "Chat → Quote → Order", runsToday: 482, successRate: 96.8, avgLatencyMs: 740, lastRun: "1m", status: "ok" }, + { id: "F-ROUTER-02", name: "Marketplace Router", runsToday: 3120, successRate: 99.2, avgLatencyMs: 210, lastRun: "15s", status: "ok" }, + { id: "F-VISION-01", name: "IoT Vision QC", runsToday: 260, successRate: 92.4, avgLatencyMs: 980, lastRun: "2m", status: "warn" }, + { id: "F-RMA-01", name: "Returns Classifier", runsToday: 58, successRate: 88.3, avgLatencyMs: 1320, lastRun: "4m", status: "error" }, +]; + +const ALERTS: Alert[] = [ + { id: "al1", title: "Low stock: Holiday Gift Set", detail: "Stock: 6 units • risk OOS in 2 days", when: "just now", level: "warn" }, + { id: "al2", title: "AI Flow degraded: IoT Vision QC", detail: "Latency > 1s for last 15 mins", when: "5m ago", level: "error" }, + { id: "al3", title: "Channel API quota nearing limit", detail: "TikTok Shop at 83% for today", when: "18m ago", level: "warn" }, +]; + +/* =========================== + Helpers +=========================== */ +const fmt = (n: number | string) => + typeof n === "number" ? n.toLocaleString() : n; +const THB = (n: number) => `฿${n.toLocaleString()}`; +const sign = (d?: number) => (d && d > 0 ? "+" : d && d < 0 ? "–" : ""); +const barWidth = (n: number) => Math.max(6, Math.min(100, n)); + +function chipStatus(ok: boolean | "warn" | "error") { + if (ok === "warn") return "bg-amber-50 text-amber-700 border border-amber-200"; + if (ok === "error") return "bg-red-50 text-red-700 border border-red-200"; + return "bg-emerald-50 text-emerald-700 border border-emerald-200"; +} + +function deviceDot(s: Device["status"]) { + return s === "ok" ? "bg-emerald-500" : s === "warn" ? "bg-amber-500" : "bg-red-500"; +} + +/* =========================== + Component +=========================== */ +export default function DashboardPage() { + const [range, setRange] = useState("7d"); + const [channel, setChannel] = useState("all"); + + const kpis = useMemo(() => KPI_BY_RANGE[range], [range]); + const mix = useMemo(() => MIX_ALL, []); + const topSkus = useMemo(() => BEST_SELLERS, []); + const lowStock = useMemo(() => LOW_STOCK, []); + const sla = useMemo(() => SLA, []); + const devices = useMemo(() => DEVICES, []); + const flows = useMemo(() => FLOWS, []); + const alerts = useMemo(() => ALERTS, []); + + return ( +
+ {/* Header / Filters */} +
+
+

EOP Dashboard

+

+ Commerce overview — marketplaces, social/chat, IoT & AI orchestration +

+
+
+
+
+ {(["today", "7d", "30d"] as const).map((r) => ( + + ))} +
+
+
+
+ {(["all", "shopee", "lazada", "tiktok", "d2c", "chat"] as const).map((c) => ( + + ))} +
+
+
+
+ + {/* KPI Cards */} +
+ {kpis.map((k) => ( +
+
{k.label}{k.hint ? ` • ${k.hint}` : ""}
+
+
+ {k.suffix === "฿" ? THB(Number(k.value)) : `${fmt(k.value)}${k.suffix ?? ""}`} +
+ {typeof k.delta === "number" && ( +
= 0 + ? "bg-emerald-50 text-emerald-700 border border-emerald-200" + : "bg-red-50 text-red-700 border border-red-200", + ].join(" ")} + > + {sign(k.delta)} + {Math.abs(k.delta).toFixed(1)}% +
+ )} +
+
+
+
+
+ ))} +
+ + {/* Channel Mix + Top SKUs */} +
+ {/* Channel Mix */} +
+
+
+

Channel mix

+ by GMV +
+
    + {mix.map((m) => ( +
  • +
    +
    {m.label}
    +
    {m.value}%
    +
    +
    +
    +
    +
  • + ))} +
+
+
+ + {/* Best Sellers */} +
+
+
+

Best sellers

+ +
+
+ + + + + + + + + + + + {topSkus.map((s) => ( + + + + + + + + ))} + +
SKUNameUnitsGMVStock
{s.sku}{s.name}{fmt(s.units)}{THB(s.gmv)} + + {fmt(s.stock ?? 0)} + +
+
+ {/* Low stock banner */} +
+ Low stock:{" "} + {lowStock + .filter((x) => (x.stock ?? 0) <= 20) + .slice(0, 3) + .map((x) => `${x.name} (${x.stock})`) + .join(", ")}{" "} + — plan replenishment & marketplace buffers +
+
+
+
+ + {/* Fulfillment & IoT */} +
+ {/* Fulfillment SLA */} +
+
+
+

Fulfillment SLA

+ warehouse & delivery +
+
    + {sla.map((f) => ( +
  • +
    +
    {f.metric}
    +
    + {typeof f.delta === "number" && ( + = 0 + ? "bg-emerald-50 text-emerald-700 border border-emerald-200" + : "bg-red-50 text-red-700 border border-red-200", + ].join(" ")} + > + {sign(f.delta)} + {Math.abs(f.delta).toFixed(1)}% + + )} + {f.value} +
    +
    +
    +
    +
    + {f.hint &&
    {f.hint}
    } +
  • + ))} +
+
+
+ + {/* IoT Device Health */} +
+
+
+

IoT device health

+ +
+
    + {devices.map((d) => ( +
  • +
    + +
    +
    {d.id} • {d.role}
    +
    {d.site} • {d.lastSeen}
    +
    +
    + + {d.status} + +
  • + ))} +
+
+
+
+ + {/* AI Orchestration & Alerts */} +
+ {/* AI Flows */} +
+
+
+

AI flows

+
+ + +
+
+
+ + + + + + + + + + + + {flows.map((f) => ( + + + + + + + + ))} + +
FlowRuns (today)SuccessAvg LatencyStatus
+
{f.name}
+
last: {f.lastRun} ago
+
{fmt(f.runsToday)}{f.successRate.toFixed(1)}%{f.avgLatencyMs} ms + + {f.status} + +
+
+
+
+ + {/* Alerts / Tasks */} +
+
+
+

Alerts

+ +
+
    + {alerts.map((a) => ( +
  • +
    +
    +
    {a.title}
    +
    {a.detail}
    +
    + + {a.when} + +
    +
  • + ))} +
+
+ +
+
+
+
+ + {/* Footer action row */} +
+ + + +
+
+ ); +} diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx new file mode 100644 index 0000000..c731e2c --- /dev/null +++ b/src/app/(protected)/layout.tsx @@ -0,0 +1,51 @@ +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"; + +export default async function ProtectedLayout({ children }: { children: ReactNode }) { + const cookieStore = await cookies(); + const access = cookieStore.get("access_token")?.value; + + if (!access) { + redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`); + } + + const jwtUser = await getUserFromToken(access!); + + const infoB64 = cookieStore.get("user_info")?.value; + let info: Record | null = null; + if (infoB64) { + try { + info = JSON.parse(Buffer.from(infoB64, "base64").toString("utf8")) as Record; + } catch { + info = null; + } + } + + const merged: UserSession = { + id: (jwtUser?.id ?? (info?.userId as string | undefined) ?? ""), + email: (jwtUser?.email ?? (info?.email as string | undefined)), + tenantKey: (jwtUser?.tenantKey ?? (info?.tenantKey as string | undefined)), + tenantId: (jwtUser?.tenantId ?? (info?.tenantId as string | undefined)), + roles: Array.isArray(jwtUser?.roles) ? jwtUser!.roles : [], + _dbg: jwtUser?._dbg, + }; + + if (!merged.id) { + redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`); + } + + const nav = buildNavForRoles(merged.roles); + + return ( + +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx new file mode 100644 index 0000000..49de0e0 --- /dev/null +++ b/src/app/(public)/layout.tsx @@ -0,0 +1,3 @@ +export default function PublicLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/(public)/login/page.tsx b/src/app/(public)/login/page.tsx new file mode 100644 index 0000000..56e6149 --- /dev/null +++ b/src/app/(public)/login/page.tsx @@ -0,0 +1,165 @@ +"use client"; + +import React, { Suspense, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import BrandLogo from "@/app/component/common/BrandLogo"; + +// บอก Next ให้บังคับ dynamic เพื่อไม่ให้พยายาม prerender แบบ static แล้วล้ม +export const dynamic = "force-dynamic"; + +function LoginFormInner() { + const sp = useSearchParams(); + const returnUrl = sp.get("returnUrl") || "/dashboard"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const router = useRouter(); + + const emailError = useMemo(() => { + if (!email) return null; + const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + return ok ? null : "กรุณากรอกอีเมลให้ถูกต้อง"; + }, [email]); + + const canSubmit = Boolean(email && password && !emailError && !loading); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + + setLoading(true); + setError(null); + setMessage(null); + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, password }), + cache: "no-store", + }); + + if (!res.ok) { + const j = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(j?.message ?? `Login failed (${res.status})`); + } + + setMessage("เข้าสู่ระบบสำเร็จ กำลังพาไปยังหน้าแดชบอร์ด…"); + router.replace(returnUrl); + } catch (ex: unknown) { + const err = ex as Error; + setError(err.message ?? "Login error"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* Brand */} +
+ +
+ + {/* Heading */} +

Sign in

+

เข้าสู่ระบบเพื่อใช้งานแพลตฟอร์ม

+ + {/* Card */} +
+
+
+ + setEmail(e.target.value)} + inputMode="email" + autoComplete="username" + type="email" + aria-invalid={!!emailError} + aria-describedby="email-error" + required + /> + {emailError && ( +
+ {emailError} +
+ )} +
+ +
+ + setPassword(e.target.value)} + type="password" + autoComplete="current-password" + required + /> +
+ + +
+ + {/* Banners */} + {message && ( +
+ {message} +
+ )} + {error && ( +
+ {error} +
+ )} +
+
+
+ ); +} + +function LoginSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default function LoginPage() { + 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 baaaba6..6c8ab56 100644 --- a/src/app/(public)/redeem/[transactionId]/page.tsx +++ b/src/app/(public)/redeem/[transactionId]/page.tsx @@ -1,175 +1,102 @@ // File: src/app/(public)/redeem/[transactionId]/page.tsx "use client"; -import { useState, useMemo } from "react"; +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"; -/* ======================= - Config -======================= */ -const API_BASE = process.env.NEXT_PUBLIC_EOP_PUBLIC_API_BASE ?? ""; - -/* ======================= - Types -======================= */ -type RedeemResponse = { - balance?: number; - voucherCode?: string; - ledgerEntryId?: string; - redeemed?: boolean; - message?: string; -}; - -/* ======================= - Utils -======================= */ -function uuidv4() { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); - return "idem-" + Date.now() + "-" + Math.random().toString(16).slice(2); -} - -function getTenantKeyFromHost() { - if (typeof window === "undefined") return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? "demo"; - const host = window.location.hostname; - const parts = host.split("."); - if (parts.length >= 3) return parts[0]; - return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? "demo"; -} - -// แปลงเลขไทย→อารบิก แล้วเหลือเฉพาะ 0-9 เท่านั้น -function toAsciiDigitsOnly(input: string) { - const map: Record = { - "๐": "0", "๑": "1", "๒": "2", "๓": "3", "๔": "4", - "๕": "5", "๖": "6", "๗": "7", "๘": "8", "๙": "9", - }; - const thToEn = input.replace(/[๐-๙]/g, (ch) => map[ch] ?? ch); - return thToEn.replace(/\D/g, ""); -} - -// รับเฉพาะเบอร์มือถือไทย 10 หลัก ที่ขึ้นต้นด้วย 06/08/09 -function isStrictThaiMobile10(digitsOnly: string) { - return /^0(6|8|9)\d{8}$/.test(digitsOnly); -} - -// รูปแบบที่ดูเหมือน +66/66xxxxx (ไม่อนุญาต) -function looksLikeCountryCodeFormat(raw: string) { - const t = raw.trim(); - return t.startsWith("+66") || /^66\d+/.test(t); -} - -/* ======================= - API -======================= */ -async function redeemPoints(input: { - tenantKey: string; - transactionId: string; - phoneDigits10: string; // 0812345678 - idempotencyKey: string; -}): Promise { - const res = await fetch(`${API_BASE}/api/v1/loyalty/redeem`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Tenant-Key": input.tenantKey, - "Idempotency-Key": input.idempotencyKey, - }, - body: JSON.stringify({ - transactionId: input.transactionId, - contact: { phone: input.phoneDigits10 }, - metadata: { source: "qr-landing" }, - }), - cache: "no-store", - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `HTTP ${res.status}`); - } - return res.json() as Promise; -} - -/* ======================= - 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 [loading, setLoading] = useState(false); + const [phoneDigits, setPhoneDigits] = useState(""); + const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const [result, setResult] = useState(null); - // Validation message - const phoneError: string | null = useMemo(() => { + const idemRef = useRef(null); + + const phoneError = useMemo(() => { if (!phoneDigits) return null; - if (looksLikeCountryCodeFormat(phoneDigits)) { - return "กรุณาใส่เบอร์รูปแบบไทย 10 หลัก เช่น 0812345678 (ไม่ต้องใส่ +66)"; - } - if (phoneDigits.length > 0 && phoneDigits.length !== 10) { - return "กรุณากรอกเป็นตัวเลข 10 หลัก"; - } - if (phoneDigits.length === 10 && !isStrictThaiMobile10(phoneDigits)) { + if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678"; + if (phoneDigits.length > 0 && phoneDigits.length !== 10) return "กรุณากรอกเป็นตัวเลข 10 หลัก"; + if (phoneDigits.length === 10 && !isThaiMobile10(phoneDigits)) return "รับเฉพาะเบอร์มือถือไทยที่ขึ้นต้นด้วย 06, 08 หรือ 09 เท่านั้น"; - } return null; }, [phoneDigits]); - const phoneValid = phoneDigits.length === 10 && !phoneError && isStrictThaiMobile10(phoneDigits); + const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits); - // Guard input — digits only (รองรับเลขไทย) function handleChange(e: React.ChangeEvent) { - const onlyDigits = toAsciiDigitsOnly(e.target.value).slice(0, 10); - setPhoneDigits(onlyDigits); + setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10)); } function handleBeforeInput(e: React.FormEvent) { - const ie = e.nativeEvent as InputEvent; - const data = (ie && "data" in ie ? ie.data : undefined) ?? ""; - if (!data) return; - if (!/^[0-9๐-๙]+$/.test(data)) e.preventDefault(); + const nativeEvt = e.nativeEvent as unknown; + const data = + nativeEvt && typeof nativeEvt === "object" && "data" in (nativeEvt as InputEvent) + ? (nativeEvt as InputEvent).data + : 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)) return; - if (/^[0-9]$/.test(e.key)) return; + if (allow.includes(e.key) || /^[0-9]$/.test(e.key)) return; e.preventDefault(); } function handlePaste(e: React.ClipboardEvent) { e.preventDefault(); - const text = e.clipboardData.getData("text") ?? ""; - const onlyDigits = toAsciiDigitsOnly(text); + const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? ""); if (!onlyDigits) return; setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10)); } - async function onSubmit(e: React.FormEvent) { + async function onSubmit(e: React.FormEvent) { e.preventDefault(); + if (!transactionId || !phoneValid || loading) return; + setLoading(true); setError(null); setMessage(null); setResult(null); try { - const tenantKey = getTenantKeyFromHost(); - const idempotencyKey = uuidv4(); + const tenantKey = resolveTenantKeyFromHost(); + idemRef.current = genIdempotencyKey(); - const data = await redeemPoints({ - tenantKey, + const payload: RedeemRequest = { transactionId, - phoneDigits10: phoneDigits, - idempotencyKey, + 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": idemRef.current!, + }, + body: JSON.stringify(payload), + cache: "no-store", }); + if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`); + + const data: RedeemResponse = await res.json(); setResult(data); setMessage("สะสมคะแนนสำเร็จ"); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้"; - setError(msg); + } catch (ex: unknown) { + const err = ex as Error; + setError(err.message ?? "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้"); } finally { setLoading(false); + idemRef.current = null; } } @@ -187,7 +114,7 @@ export default function RedeemPage() { Transaction: {transactionId || "—"}

- {/* Card — เรียบแต่เนี๊ยบขึ้น */} + {/* Card */}
{!transactionId && (
@@ -197,14 +124,10 @@ export default function RedeemPage() {
- + - {/* Banners */} {message && (
สะสมคะแนนสำเร็จ diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..60aeb9e --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE, TENANT_KEY, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config"; +import type { UpstreamLoginResponse } from "@/types/auth"; + +const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true"; +const TIMEOUT_MS = Number(process.env.LOGIN_TIMEOUT_MS ?? 15000); + +function redact>(obj: T): T { + try { + if (!obj || typeof obj !== "object") return obj; + const clone = { ...obj } as Record; + if ("password" in clone) clone.password = "***REDACTED***"; + return clone as T; + } catch { + return obj; + } +} + +export async function POST(req: NextRequest) { + const url = `${API_BASE}/api/authentication/login`; + let upstreamStatus = 0; + + try { + const body = (await req.json().catch(() => ({}))) as Record; + const printBody = redact(body); + + if (DEBUG_AUTH) { + console.log("[auth/login] → upstream", { url, tenant: TENANT_KEY, apiBase: API_BASE, body: printBody }); + } + + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), TIMEOUT_MS); + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Tenant": TENANT_KEY }, + body: JSON.stringify(body), + redirect: "manual", + signal: ac.signal, + }).catch((e) => { + const err = e as Error; + throw new Error(`Upstream fetch error: ${err.message}`); + }); + clearTimeout(t); + + upstreamStatus = res.status; + + const rawText = await res.text(); + let data: UpstreamLoginResponse | { _raw?: string }; + try { + data = rawText ? (JSON.parse(rawText) as UpstreamLoginResponse) : ({} as UpstreamLoginResponse); + } catch { + data = { _raw: rawText }; + } + + if (DEBUG_AUTH) { + const setCookieHeader = res.headers.get("set-cookie"); + console.log("[auth/login] ← upstream", { + status: res.status, + hasAccessToken: Boolean((data as UpstreamLoginResponse).access_token), + setCookiePresent: Boolean(setCookieHeader), + setCookiePreview: setCookieHeader?.slice(0, 160), + bodyPreview: rawText.slice(0, 200), + }); + } + + if (!res.ok) { + return NextResponse.json( + { message: (data as { message?: string })?.message ?? "Login failed", upstreamStatus: res.status, upstreamBodyPreview: rawText.slice(0, 400) }, + { status: res.status }, + ); + } + + const login = data as UpstreamLoginResponse; + + const nextRes = NextResponse.json( + { user: login.user, token_type: login.token_type, expires_at: login.expires_at }, + { status: 200 }, + ); + + // user_info cookie + try { + const userInfo = { + userId: login?.user?.userId, + email: login?.user?.email, + tenantKey: login?.user?.tenantKey, + tenantId: login?.user?.tenantId, + }; + const b64 = Buffer.from(JSON.stringify(userInfo)).toString("base64"); + nextRes.cookies.set("user_info", b64, { + httpOnly: false, + secure: COOKIE_SECURE, + sameSite: COOKIE_SAMESITE, + path: "/", + expires: login?.expires_at ? new Date(login.expires_at) : undefined, + }); + } catch (e) { + const err = e as Error; + console.warn("[auth/login] could not set user_info cookie:", err.message); + } + + if (login?.access_token && login?.expires_at) { + nextRes.cookies.set("access_token", login.access_token, { + httpOnly: false, + secure: COOKIE_SECURE, + sameSite: COOKIE_SAMESITE, + path: "/", + expires: new Date(login.expires_at), + }); + } + + const setCookieHeader = res.headers.get("set-cookie") ?? ""; + const parts = setCookieHeader.split(/,(?=\s*[a-zA-Z0-9_\-]+=)/).map((s) => s.trim()); + const refreshPart = parts.find((p) => p.toLowerCase().startsWith("refresh_token=")); + + if (refreshPart) { + const match = refreshPart.match(/^refresh_token=([^;]+)/i); + if (match) { + const refreshValue = decodeURIComponent(match[1]); + const expMatch = refreshPart.match(/expires=([^;]+)/i); + const expires = expMatch ? new Date(expMatch[1]) : undefined; + + nextRes.cookies.set("refresh_token", refreshValue, { + httpOnly: true, + secure: COOKIE_SECURE, + sameSite: COOKIE_SAMESITE, + path: "/", + expires, + }); + } + } else if (DEBUG_AUTH) { + console.warn("[auth/login] No refresh_token in upstream Set-Cookie."); + } + + return nextRes; + } catch (e) { + const err = e as Error; + const msg = err.name === "AbortError" ? `Upstream timeout > ${TIMEOUT_MS}ms` : err.message; + console.error("[auth/login] ERROR", { message: msg, upstreamStatus }); + return NextResponse.json( + { message: "Server error", detail: msg, upstreamStatus }, + { status: upstreamStatus || 500 }, + ); + } +} \ No newline at end of file diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..2341dd1 --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config"; +import { resolveTenantFromRequest } from "@/lib/server-tenant"; +import type { UpstreamRefreshResponse } from "@/types/auth"; + +const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true"; + +export async function POST(req: NextRequest) { + const tenantKey = resolveTenantFromRequest(req); + const url = `${API_BASE}/api/authentication/refresh`; + const refresh = req.cookies.get("refresh_token")?.value; + + try { + const r = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Tenant": tenantKey, + ...(refresh ? { Cookie: `refresh_token=${encodeURIComponent(refresh)}` } : {}), + }, + body: JSON.stringify({}), + redirect: "manual", + }); + + const rawText = await r.text(); + let data: UpstreamRefreshResponse | { _raw?: string }; + try { + data = rawText ? (JSON.parse(rawText) as UpstreamRefreshResponse) : ({} as UpstreamRefreshResponse); + } catch { + data = { _raw: rawText }; + } + + if (DEBUG_AUTH) { + console.log("[auth/refresh] ← upstream", { + status: r.status, + hasAccessToken: Boolean((data as UpstreamRefreshResponse)?.access_token), + bodyPreview: rawText.slice(0, 160), + }); + } + + if (!r.ok) { + return NextResponse.json({ message: (data as { message?: string })?.message ?? "Refresh failed" }, { status: r.status }); + } + + const nextRes = NextResponse.json( + { token_type: (data as UpstreamRefreshResponse).token_type ?? "Bearer", expires_at: (data as UpstreamRefreshResponse).expires_at }, + { status: 200 }, + ); + + if ((data as UpstreamRefreshResponse)?.access_token && (data as UpstreamRefreshResponse)?.expires_at) { + nextRes.cookies.set("access_token", (data as UpstreamRefreshResponse).access_token!, { + httpOnly: false, + secure: COOKIE_SECURE, + sameSite: COOKIE_SAMESITE, + path: "/", + expires: new Date((data as UpstreamRefreshResponse).expires_at!), + }); + } + + const setCookieHeader = r.headers.get("set-cookie") ?? ""; + const parts = setCookieHeader.split(/,(?=\s*[a-zA-Z0-9_\-]+=)/).map((s) => s.trim()); + const refreshPart = parts.find((p) => p.toLowerCase().startsWith("refresh_token=")); + if (refreshPart) { + const match = refreshPart.match(/^refresh_token=([^;]+)/i); + if (match) { + const refreshValue = decodeURIComponent(match[1]); + const expMatch = refreshPart.match(/expires=([^;]+)/i); + const expires = expMatch ? new Date(expMatch[1]) : undefined; + + nextRes.cookies.set("refresh_token", refreshValue, { + httpOnly: true, + secure: COOKIE_SECURE, + sameSite: COOKIE_SAMESITE, + path: "/", + expires, + }); + } + } + + return nextRes; + } catch (e) { + const err = e as Error; + console.error("[auth/refresh] error:", err.message); + return NextResponse.json({ message: "Server error", detail: err.message }, { status: 500 }); + } +} diff --git a/src/app/api/crm/redeem/route.ts b/src/app/api/crm/redeem/route.ts new file mode 100644 index 0000000..b3ad5fb --- /dev/null +++ b/src/app/api/crm/redeem/route.ts @@ -0,0 +1,47 @@ +// File: src/app/api/public/redeem/route.ts +import { NextRequest, NextResponse } from "next/server"; +// ถ้ามี type RedeemRequest/RedeemResponse อยู่แล้ว ใช้ของโปรเจกต์ +// import type { RedeemRequest, RedeemResponse } from "@/types/crm"; + +const API_BASE = process.env.EOP_PUBLIC_API_BASE ?? "http://127.0.0.1:5063"; + +// ถ้ายังไม่มีไฟล์ types/crm ให้ใช้ fallback แบบหลวมๆ (ไม่ใช่ any) +type RedeemRequestFallback = Record; +type RedeemResponseFallback = unknown; + +export async function POST(req: NextRequest) { + try { + // อ่าน body แบบ unknown แล้วค่อย stringify กลับ — ปลอดภัยกว่า any + const body = (await req.json().catch(() => ({}))) as unknown; + + // header จากฝั่ง client + const tenant = req.headers.get("x-tenant-key") ?? "default"; + const idem = req.headers.get("idempotency-key") ?? undefined; + + const r = await fetch(`${API_BASE}/api/v1/loyalty/redeem`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Tenant-Key": tenant, + ...(idem ? { "Idempotency-Key": idem } : {}), + }, + body: JSON.stringify(body as RedeemRequestFallback), + cache: "no-store", + }); + + const text = await r.text(); + return new NextResponse(text, { + status: r.status, + headers: { + "Content-Type": r.headers.get("Content-Type") ?? "application/json", + "Cache-Control": "no-store", + }, + }); + } catch (e: unknown) { + const err = e as Error; + return NextResponse.json( + { message: err.message ?? "Server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..442756b --- /dev/null +++ b/src/app/api/proxy/[...path]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { API_BASE } from "@/lib/config"; +import { resolveTenantFromRequest } from "@/lib/server-tenant"; + +type Ctx = { params: Promise<{ path: string[] }> }; + +function buildHeaders(req: NextRequest, tenantKey: string, extra?: Record) { + const access = req.cookies.get("access_token")?.value; + return { + "X-Tenant": tenantKey, + ...(access ? { Authorization: `Bearer ${access}` } : {}), + ...(extra ?? {}), + }; +} + +async function forward(req: NextRequest, ctx: Ctx, method: string) { + const { path } = await ctx.params; + const tenantKey = resolveTenantFromRequest(req); + const url = `${API_BASE}/${path.join("/")}${req.nextUrl.search}`; + + try { + const init: RequestInit = { method, headers: buildHeaders(req, tenantKey) }; + + if (method !== "GET" && method !== "HEAD") { + const raw = await req.text(); + init.body = raw; + const ct = req.headers.get("content-type"); + if (ct) (init.headers as Record)["Content-Type"] = ct; + (init.headers as Record)["Accept"] = req.headers.get("accept") ?? "application/json"; + } else { + (init.headers as Record)["Accept"] = req.headers.get("accept") ?? "application/json"; + } + + const r = await fetch(url, init); + const text = await r.text(); + + return new NextResponse(text, { + status: r.status, + headers: { "Content-Type": r.headers.get("Content-Type") ?? "application/json" }, + }); + } catch (e) { + const err = e as Error; + console.error("[proxy]", method, url, "error:", err.message); + return NextResponse.json({ message: "Proxy error", detail: err.message }, { status: 502 }); + } +} + +export async function GET(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "GET"); } +export async function POST(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "POST"); } +export async function PUT(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "PUT"); } +export async function PATCH(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "PATCH"); } +export async function DELETE(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "DELETE"); } +export async function HEAD(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "HEAD"); } diff --git a/src/app/component/layout/AppShell.tsx b/src/app/component/layout/AppShell.tsx new file mode 100644 index 0000000..6b9637f --- /dev/null +++ b/src/app/component/layout/AppShell.tsx @@ -0,0 +1,160 @@ +// File: src/app/component/layout/AppShell.tsx +"use client"; + +import { ReactNode, useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { UserSession } from "@/types/auth"; + +type NavItem = { label: string; href: string; icon?: React.ReactNode; match?: RegExp }; +type Props = { user: UserSession; nav: NavItem[]; children: ReactNode }; + +export default function AppShell({ user, nav, children }: Props) { + const [sidebarOpen, setSidebarOpen] = useState(true); + const pathname = usePathname(); + + const initials = useMemo(() => { + const email = user?.email ?? ""; + const namePart = email.split("@")[0] || "U"; + return namePart.slice(0, 2).toUpperCase(); + }, [user?.email]); + + const year = new Date().getFullYear(); + const isActive = (item: NavItem): boolean => + Boolean((item.match && item.match.test(pathname)) || pathname === item.href); + + return ( +
+ {/* Sidebar (desktop) */} + + + {/* Main */} +
+ {/* Topbar */} +
+
+
+ + +
+ Dashboard + + Overview +
+
+ +
+
+
+ {initials} +
+
+
{user?.email ?? "—"}
+
{user?.tenantKey ?? "—"}
+
+
+
+
+
+ + {/* Content */} +
+
+ {children} +
+
+ + {/* Footer */} +
+
+ © {year} EOP + Built for enterprise operations +
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index a932894..ac1ad3f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,2 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- - -
- -
- ); -} +import { redirect } from "next/navigation"; +export default function Home() { redirect("/login"); } \ No newline at end of file diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..c4ef5f1 --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,15 @@ +"use client"; +import { ThemeProvider } from "next-themes"; +// import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode } from "react"; + +export default function Providers({ children }: { children: ReactNode }) { + // const [client] = useState(() => new QueryClient()); + return ( + // + + {children} + + // + ); +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..d23b650 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,6 @@ +export const API_BASE = process.env.API_BASE ?? "http://127.0.0.1:5063"; +export const TENANT_KEY = process.env.TENANT_KEY ?? "default"; +const parseBool = (v?: string) => (v ?? "").toLowerCase() === "true"; +export const COOKIE_SECURE = parseBool(process.env.COOKIE_SECURE) ?? false; // dev same-host ใช้ false +export const COOKIE_SAMESITE = + (process.env.COOKIE_SAMESITE as "lax" | "strict" | "none" | undefined) ?? "strict"; diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts new file mode 100644 index 0000000..d0dfba8 --- /dev/null +++ b/src/lib/idempotency.ts @@ -0,0 +1,4 @@ +export function genIdempotencyKey() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return "idem-" + Date.now() + "-" + Math.random().toString(16).slice(2); +} \ No newline at end of file diff --git a/src/lib/nav.ts b/src/lib/nav.ts new file mode 100644 index 0000000..fddf27e --- /dev/null +++ b/src/lib/nav.ts @@ -0,0 +1,16 @@ +// lib/nav.ts +type NavItem = { label: string; href: string }; + +export function buildNavForRoles(roles: string[]): NavItem[] { + const base: NavItem[] = [ + { label: "Dashboard", href: "/dashboard" }, + ]; + if (roles.includes("admin")) { + base.push({ label: "Users", href: "/users" }); + base.push({ label: "Settings", href: "/settings" }); + } + if (roles.includes("ops")) { + base.push({ label: "Loyalty", href: "/loyalty" }); + } + return base; +} diff --git a/src/lib/phone.ts b/src/lib/phone.ts new file mode 100644 index 0000000..48fb48f --- /dev/null +++ b/src/lib/phone.ts @@ -0,0 +1,17 @@ +const mapTH: Record = { + "๐":"0","๑":"1","๒":"2","๓":"3","๔":"4","๕":"5","๖":"6","๗":"7","๘":"8","๙":"9", +}; + +export function onlyAsciiDigits(input: string) { + const thToEn = input.replace(/[๐-๙]/g, (ch) => mapTH[ch] ?? ch); + return thToEn.replace(/\D/g, ""); +} + +export function isThaiMobile10(d: string) { + return /^0(6|8|9)\d{8}$/.test(d); +} + +export function looksLikeCountryCode(raw: string) { + const t = raw.trim(); + return t.startsWith("+66") || /^66\d+/.test(t); +} diff --git a/src/lib/server-auth.ts b/src/lib/server-auth.ts new file mode 100644 index 0000000..0fb0de7 --- /dev/null +++ b/src/lib/server-auth.ts @@ -0,0 +1,166 @@ +import jwt from "jsonwebtoken"; +import type { UserSession } from "@/types/auth/user"; + +type Claims = { + sub: string; + tenant_id?: string; + email?: string; + roles?: string[]; + tenant_key?: string; + tenantKey?: string; + exp?: number; + nbf?: number; + iss?: unknown; + aud?: unknown; + sid?: unknown; + jti?: unknown; + [k: string]: unknown; +}; + +const DEBUG_TOKEN = (process.env.DEBUG_TOKEN ?? "true").toLowerCase() === "true"; + +function redactToken(t?: string) { + if (!t) return ""; + const raw = t.startsWith("Bearer ") ? t.slice(7) : t; + if (raw.length <= 12) return raw.replace(/./g, "*"); + return `${raw.slice(0, 6)}…${raw.slice(-6)}`; +} + +function parseBearer(input: string) { + return input?.startsWith("Bearer ") ? input.slice(7) : input; +} + +function decodeBase64UrlPart(part: string): unknown { + try { + const pad = + part.length % 4 === 2 ? "==" : part.length % 4 === 3 ? "=" : ""; + const s = part.replace(/-/g, "+").replace(/_/g, "/") + pad; + const json = Buffer.from(s, "base64").toString("utf8"); + return JSON.parse(json); + } catch { + return null; + } +} + +export async function getUserFromToken( + token: string, +): Promise { + const input = parseBearer(token); + + if (DEBUG_TOKEN) { + // eslint-disable-next-line no-console + console.log("[server-auth] getUserFromToken() input:", { + tokenPreview: redactToken(token), + hasBearerPrefix: token?.startsWith?.("Bearer "), + }); + } + + if (!input) { + if (DEBUG_TOKEN) console.warn("[server-auth] no token provided"); + return null; + } + + const HS_SECRET = process.env.JWT_SECRET; + const RS_PUBLIC = process.env.JWT_PUBLIC_KEY; + + let decoded: Claims | null = null; + let headerAlg: string | undefined; + + try { + const [hPart] = input.split("."); + const hdr = decodeBase64UrlPart(hPart) as { alg?: string } | null; + headerAlg = hdr?.alg; + + if (HS_SECRET) { + decoded = jwt.verify(input, HS_SECRET, { + algorithms: ["HS256", "HS384", "HS512"], + clockTolerance: 5, + }) as Claims; + if (DEBUG_TOKEN) + console.log("[server-auth] verified with HS secret", { alg: headerAlg }); + } else if (RS_PUBLIC) { + decoded = jwt.verify(input, RS_PUBLIC, { + algorithms: ["RS256", "RS384", "RS512"], + clockTolerance: 5, + }) as Claims; + if (DEBUG_TOKEN) + console.log("[server-auth] verified with RS public key", { + alg: headerAlg, + }); + } else { + decoded = jwt.decode(input) as Claims | null; + if (DEBUG_TOKEN) + console.warn("[server-auth] decoded WITHOUT verification", { + alg: headerAlg, + }); + } + } catch (e: unknown) { + const err = e as Error; + if (DEBUG_TOKEN) { + console.error("[server-auth] verify/decode failed:", { + message: err.message, + name: err.name, + alg: headerAlg, + }); + } + try { + const parts = input.split("."); + if (parts.length >= 2) { + const pay = decodeBase64UrlPart(parts[1]) as Claims | null; + decoded = pay; + if (DEBUG_TOKEN) + console.warn("[server-auth] manual payload decode fallback used"); + } + } catch { + /* ignore */ + } + } + + if (!decoded) { + if (DEBUG_TOKEN) console.warn("[server-auth] decoded = null"); + return null; + } + + const now = Math.floor(Date.now() / 1000); + if (typeof decoded.nbf === "number" && now + 5 < decoded.nbf) { + if (DEBUG_TOKEN) + console.warn("[server-auth] token not yet valid", { now, nbf: decoded.nbf }); + } + if (typeof decoded.exp === "number" && now - 5 > decoded.exp) { + if (DEBUG_TOKEN) + console.warn("[server-auth] token expired", { now, exp: decoded.exp }); + } + + const user: UserSession = { + id: decoded.sub ?? "", + email: decoded.email, + tenantId: decoded.tenant_id, + tenantKey: decoded.tenant_key ?? decoded.tenantKey, + roles: Array.isArray(decoded.roles) ? decoded.roles : [], + _dbg: DEBUG_TOKEN + ? { + alg: headerAlg, + hasExp: typeof decoded.exp === "number", + hasNbf: typeof decoded.nbf === "number", + iss: decoded.iss, + aud: decoded.aud, + sid: decoded.sid, + jti: decoded.jti, + } + : undefined, + }; + + if (DEBUG_TOKEN) { + console.log("[server-auth] user parsed:", { + id: user.id, + email: user.email, + tenantId: user.tenantId, + tenantKey: user.tenantKey, + roles: user.roles, + alg: user._dbg?.alg, + }); + } + + if (!user.id && DEBUG_TOKEN) console.warn("[server-auth] missing sub"); + return user; +} \ No newline at end of file diff --git a/src/lib/server-tenant.ts b/src/lib/server-tenant.ts new file mode 100644 index 0000000..3b47348 --- /dev/null +++ b/src/lib/server-tenant.ts @@ -0,0 +1,47 @@ +import { NextRequest } from "next/server"; + +/** base64url → JSON (แบบเบา ๆ) */ +function decodeB64Json(b64: string) { + try { + const s = b64.replace(/-/g, "+").replace(/_/g, "/"); + const pad = s.length % 4 === 2 ? "==" + : s.length % 4 === 3 ? "=" + : ""; + const json = Buffer.from(s + pad, "base64").toString("utf8"); + return JSON.parse(json); + } catch { + return null; + } +} + +/** พยายามหา tenant จาก cookie/user_info → JWT → subdomain → env → "default" */ +export function resolveTenantFromRequest(req: NextRequest): string { + // 1) จาก cookie user_info (ตั้งตอน login) + const ui = req.cookies.get("user_info")?.value; + if (ui) { + try { + const parsed = JSON.parse(Buffer.from(ui, "base64").toString("utf8")); + if (parsed?.tenantKey && typeof parsed.tenantKey === "string") return parsed.tenantKey; + } catch { /* ignore */ } + } + + // 2) จาก access_token (payload.tenant_key | payload.tenantKey) + const access = req.cookies.get("access_token")?.value; + if (access && access.split(".").length >= 2) { + const payloadB64 = access.split(".")[1]; + const payload = decodeB64Json(payloadB64); + const tk = payload?.tenant_key ?? payload?.tenantKey; + if (typeof tk === "string" && tk) return tk; + } + + // 3) จาก host (subdomain) + const host = req.headers.get("host") ?? ""; + const parts = host.split("."); + if (parts.length >= 3) return parts[0]; + + // 4) จาก env + if (process.env.DEFAULT_TENANT_KEY) return process.env.DEFAULT_TENANT_KEY; + + // fallback + return "default"; +} diff --git a/src/lib/tenant.ts b/src/lib/tenant.ts new file mode 100644 index 0000000..d9244f6 --- /dev/null +++ b/src/lib/tenant.ts @@ -0,0 +1,9 @@ +export function resolveTenantKeyFromHost(defaultKey = "public") { + if (typeof window === "undefined") { + return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? defaultKey; + } + const host = window.location.hostname; // aaaa.example.com + const parts = host.split("."); + if (parts.length >= 3) return parts[0]; // subdomain เป็น tenant + return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? defaultKey; +} diff --git a/src/types/auth/index.ts b/src/types/auth/index.ts new file mode 100644 index 0000000..c9bfb2c --- /dev/null +++ b/src/types/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./user"; +export * from "./upstream"; \ No newline at end of file diff --git a/src/types/auth/upstream.ts b/src/types/auth/upstream.ts new file mode 100644 index 0000000..84572f2 --- /dev/null +++ b/src/types/auth/upstream.ts @@ -0,0 +1,19 @@ +export type UpstreamLoginUser = { + userId: string; + tenantId: string; + email?: string; + tenantKey?: string; +}; + +export type UpstreamLoginResponse = { + user: UpstreamLoginUser; + access_token?: string; + token_type?: string; + expires_at?: string; +}; + +export type UpstreamRefreshResponse = { + access_token?: string; + token_type?: string; + expires_at?: string; +}; \ No newline at end of file diff --git a/src/types/auth/user.ts b/src/types/auth/user.ts new file mode 100644 index 0000000..4a3b381 --- /dev/null +++ b/src/types/auth/user.ts @@ -0,0 +1,18 @@ +export type UserDebug = { + alg: string | undefined; + hasExp: boolean; + hasNbf: boolean; + iss: unknown; + aud: unknown; + sid: unknown; + jti: unknown; +}; + +export type UserSession = { + id: string; + email?: string; + tenantId?: string; + tenantKey?: string; + roles: string[]; + _dbg?: UserDebug; +}; \ No newline at end of file diff --git a/src/types/crm/index.ts b/src/types/crm/index.ts new file mode 100644 index 0000000..2d2cf4f --- /dev/null +++ b/src/types/crm/index.ts @@ -0,0 +1 @@ +export * from "./loyalty"; \ No newline at end of file diff --git a/src/types/crm/loyalty.ts b/src/types/crm/loyalty.ts new file mode 100644 index 0000000..6b6c63b --- /dev/null +++ b/src/types/crm/loyalty.ts @@ -0,0 +1,13 @@ +export type RedeemRequest = { + transactionId: string; + contact: { phone: string }; + metadata?: Record; +}; + +export type RedeemResponse = { + balance?: number; + voucherCode?: string; + ledgerEntryId?: string; + redeemed?: boolean; + message?: string; +};