Add Login and Dashboard
This commit is contained in:
153
package-lock.json
generated
153
package-lock.json
generated
@@ -8,7 +8,10 @@
|
|||||||
"name": "amrez-eop-app",
|
"name": "amrez-eop-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
@@ -1290,11 +1293,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.18",
|
"version": "20.19.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.18.tgz",
|
||||||
"integrity": "sha512-KeYVbfnbsBCyKG8e3gmUqAfyZNcoj/qpEbHRkQkfZdKOBrU7QQ+BsTdfqLSWX9/m1ytYreMhpKvp+EZi3UFYAg==",
|
"integrity": "sha512-KeYVbfnbsBCyKG8e3gmUqAfyZNcoj/qpEbHRkQkfZdKOBrU7QQ+BsTdfqLSWX9/m1ytYreMhpKvp+EZi3UFYAg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2204,6 +2222,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
@@ -2526,6 +2550,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
@@ -4141,6 +4174,28 @@
|
|||||||
"json5": "lib/cli.js"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -4157,6 +4212,27 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -4456,6 +4532,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -4463,6 +4575,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -4570,7 +4688,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -5204,6 +5331,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -5249,7 +5396,6 @@
|
|||||||
"version": "7.7.2",
|
"version": "7.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -5923,7 +6069,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -9,19 +9,22 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"next": "15.5.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0"
|
||||||
"next": "15.5.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.4",
|
"eslint-config-next": "15.5.4",
|
||||||
"@eslint/eslintrc": "^3"
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
495
src/app/(protected)/dashboard/page.tsx
Normal file
495
src/app/(protected)/dashboard/page.tsx
Normal file
@@ -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<Range, Kpi[]> = {
|
||||||
|
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<Range>("7d");
|
||||||
|
const [channel, setChannel] = useState<Channel>("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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header / Filters */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight">EOP Dashboard</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Commerce overview — marketplaces, social/chat, IoT & AI orchestration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="rounded-2xl border border-neutral-200/70 bg-white/70 p-1 shadow-sm backdrop-blur">
|
||||||
|
<div className="flex">
|
||||||
|
{(["today", "7d", "30d"] as const).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setRange(r)}
|
||||||
|
className={[
|
||||||
|
"rounded-xl px-3 py-1.5 text-sm transition",
|
||||||
|
range === r ? "bg-neutral-900 text-white" : "text-neutral-700 hover:bg-neutral-100/80",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{r.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-neutral-200/70 bg-white/70 p-1 shadow-sm backdrop-blur">
|
||||||
|
<div className="flex">
|
||||||
|
{(["all", "shopee", "lazada", "tiktok", "d2c", "chat"] as const).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setChannel(c)}
|
||||||
|
className={[
|
||||||
|
"rounded-xl px-3 py-1.5 text-sm transition",
|
||||||
|
channel === c ? "bg-neutral-900 text-white" : "text-neutral-700 hover:bg-neutral-100/80",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{c.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{kpis.map((k) => (
|
||||||
|
<article
|
||||||
|
key={k.label}
|
||||||
|
className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-neutral-500">{k.label}{k.hint ? ` • ${k.hint}` : ""}</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<div className="text-2xl font-bold tracking-tight">
|
||||||
|
{k.suffix === "฿" ? THB(Number(k.value)) : `${fmt(k.value)}${k.suffix ?? ""}`}
|
||||||
|
</div>
|
||||||
|
{typeof k.delta === "number" && (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"rounded-full px-2 py-0.5 text-[11px]",
|
||||||
|
k.delta >= 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)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||||
|
<div
|
||||||
|
className="h-full bg-neutral-900"
|
||||||
|
style={{ width: `${barWidth(30 + Math.random() * 50)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Channel Mix + Top SKUs */}
|
||||||
|
<section className="grid gap-6 lg:grid-cols-12">
|
||||||
|
{/* Channel Mix */}
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Channel mix</h2>
|
||||||
|
<span className="text-xs text-neutral-500">by GMV</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{mix.map((m) => (
|
||||||
|
<li key={m.key} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">{m.label}</div>
|
||||||
|
<div className="text-sm">{m.value}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||||
|
<div className="h-full bg-neutral-900" style={{ width: `${barWidth(m.value)}%` }} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Best Sellers */}
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Best sellers</h2>
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
Catalog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="text-left text-neutral-500">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pr-4">SKU</th>
|
||||||
|
<th className="pb-2 pr-4">Name</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">Units</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">GMV</th>
|
||||||
|
<th className="pb-2 text-right">Stock</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200/70">
|
||||||
|
{topSkus.map((s) => (
|
||||||
|
<tr key={s.sku} className="align-middle">
|
||||||
|
<td className="py-2 pr-4 font-mono">{s.sku}</td>
|
||||||
|
<td className="py-2 pr-4">{s.name}</td>
|
||||||
|
<td className="py-2 pr-4 text-right">{fmt(s.units)}</td>
|
||||||
|
<td className="py-2 pr-4 text-right">{THB(s.gmv)}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"rounded-full px-2 py-0.5 text-[11px]",
|
||||||
|
(s.stock ?? 0) < 10
|
||||||
|
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||||
|
: "bg-neutral-50 text-neutral-700 border border-neutral-200",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{fmt(s.stock ?? 0)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Low stock banner */}
|
||||||
|
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||||
|
Low stock:{" "}
|
||||||
|
{lowStock
|
||||||
|
.filter((x) => (x.stock ?? 0) <= 20)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((x) => `${x.name} (${x.stock})`)
|
||||||
|
.join(", ")}{" "}
|
||||||
|
— plan replenishment & marketplace buffers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fulfillment & IoT */}
|
||||||
|
<section className="grid gap-6 lg:grid-cols-12">
|
||||||
|
{/* Fulfillment SLA */}
|
||||||
|
<div className="lg:col-span-6">
|
||||||
|
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Fulfillment SLA</h2>
|
||||||
|
<span className="text-xs text-neutral-500">warehouse & delivery</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{sla.map((f) => (
|
||||||
|
<li key={f.metric} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm">{f.metric}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{typeof f.delta === "number" && (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"rounded-full px-2 py-0.5 text-[11px]",
|
||||||
|
f.delta >= 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)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-semibold">{f.value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||||
|
<div className="h-full bg-neutral-900" style={{ width: `${barWidth(parseFloat(f.value))}%` }} />
|
||||||
|
</div>
|
||||||
|
{f.hint && <div className="mt-1 text-xs text-neutral-500">{f.hint}</div>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IoT Device Health */}
|
||||||
|
<div className="lg:col-span-6">
|
||||||
|
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">IoT device health</h2>
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
Device console
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{devices.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.id}
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-neutral-200/70 bg-white/60 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${deviceDot(d.status)}`} />
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-medium">{d.id} • {d.role}</div>
|
||||||
|
<div className="text-xs text-neutral-500">{d.site} • {d.lastSeen}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[11px] ${chipStatus(d.status === "ok" ? true : d.status === "warn" ? "warn" : "error")}`}>
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* AI Orchestration & Alerts */}
|
||||||
|
<section className="grid gap-6 lg:grid-cols-12">
|
||||||
|
{/* AI Flows */}
|
||||||
|
<div className="lg:col-span-8">
|
||||||
|
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">AI flows</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
Orchestrator
|
||||||
|
</button>
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
New flow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="text-left text-neutral-500">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pr-4">Flow</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">Runs (today)</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">Success</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">Avg Latency</th>
|
||||||
|
<th className="pb-2 text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200/70">
|
||||||
|
{flows.map((f) => (
|
||||||
|
<tr key={f.id}>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<div className="font-medium">{f.name}</div>
|
||||||
|
<div className="text-xs text-neutral-500">last: {f.lastRun} ago</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right">{fmt(f.runsToday)}</td>
|
||||||
|
<td className="py-2 pr-4 text-right">{f.successRate.toFixed(1)}%</td>
|
||||||
|
<td className="py-2 pr-4 text-right">{f.avgLatencyMs} ms</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"rounded-full px-2 py-0.5 text-[11px]",
|
||||||
|
f.status === "ok"
|
||||||
|
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||||
|
: f.status === "warn"
|
||||||
|
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||||
|
: "bg-red-50 text-red-700 border border-red-200",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{f.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts / Tasks */}
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Alerts</h2>
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
Rules
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{alerts.map((a) => (
|
||||||
|
<li key={a.id} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{a.title}</div>
|
||||||
|
<div className="text-xs text-neutral-500">{a.detail}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"shrink-0 rounded-full px-2 py-0.5 text-[11px]",
|
||||||
|
a.level === "info"
|
||||||
|
? "bg-neutral-50 text-neutral-700 border border-neutral-200"
|
||||||
|
: a.level === "warn"
|
||||||
|
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||||
|
: "bg-red-50 text-red-700 border border-red-200",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{a.when}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4 text-right">
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
View all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer action row */}
|
||||||
|
<section className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
Export report
|
||||||
|
</button>
|
||||||
|
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
||||||
|
Manage channels
|
||||||
|
</button>
|
||||||
|
<button className="rounded-xl bg-neutral-900 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-neutral-800">
|
||||||
|
Create campaign
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/app/(protected)/layout.tsx
Normal file
51
src/app/(protected)/layout.tsx
Normal file
@@ -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<string, unknown> | null = null;
|
||||||
|
if (infoB64) {
|
||||||
|
try {
|
||||||
|
info = JSON.parse(Buffer.from(infoB64, "base64").toString("utf8")) as Record<string, unknown>;
|
||||||
|
} 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 (
|
||||||
|
<AppShell user={merged} nav={nav}>
|
||||||
|
<main id="main" className="flex-1 p-4 sm:p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/(public)/layout.tsx
Normal file
3
src/app/(public)/layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div id="main" className="min-h-screen">{children}</div>;
|
||||||
|
}
|
||||||
165
src/app/(public)/login/page.tsx
Normal file
165
src/app/(public)/login/page.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<section className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center px-4">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<BrandLogo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h1 className="mt-5 text-3xl font-extrabold tracking-tight">Sign in</h1>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">เข้าสู่ระบบเพื่อใช้งานแพลตฟอร์ม</p>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-800">อีเมล</label>
|
||||||
|
<input
|
||||||
|
className={`mt-1 h-12 w-full rounded-lg border px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 ${
|
||||||
|
emailError
|
||||||
|
? "border-red-400 focus:ring-red-200"
|
||||||
|
: "border-neutral-300 focus:ring-neutral-200"
|
||||||
|
}`}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="username"
|
||||||
|
type="email"
|
||||||
|
aria-invalid={!!emailError}
|
||||||
|
aria-describedby="email-error"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{emailError && (
|
||||||
|
<div className="mt-1 text-xs text-red-600" id="email-error">
|
||||||
|
{emailError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-800">รหัสผ่าน</label>
|
||||||
|
<input
|
||||||
|
className="mt-1 h-12 w-full rounded-lg border border-neutral-300 px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 focus:ring-neutral-200"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="w-full rounded-full bg-neutral-900 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800 active:scale-[.99] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "กำลังเข้าสู่ระบบ..." : "เข้าสู่ระบบ"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Banners */}
|
||||||
|
{message && (
|
||||||
|
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginSkeleton() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<section className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center px-4">
|
||||||
|
<div className="h-8 w-24 animate-pulse rounded bg-neutral-200" />
|
||||||
|
<div className="mt-5 h-8 w-40 animate-pulse rounded bg-neutral-200" />
|
||||||
|
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-10 w-full animate-pulse rounded bg-neutral-200" />
|
||||||
|
<div className="h-10 w-full animate-pulse rounded bg-neutral-200" />
|
||||||
|
<div className="h-10 w-full animate-pulse rounded bg-neutral-900/20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoginSkeleton />}>
|
||||||
|
<LoginFormInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,175 +1,102 @@
|
|||||||
// File: src/app/(public)/redeem/[transactionId]/page.tsx
|
// File: src/app/(public)/redeem/[transactionId]/page.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import BrandLogo from "@/app/component/common/BrandLogo";
|
import BrandLogo from "@/app/component/common/BrandLogo";
|
||||||
|
import { genIdempotencyKey } from "@/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<string, string> = {
|
|
||||||
"๐": "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<RedeemResponse> {
|
|
||||||
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<RedeemResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =======================
|
|
||||||
Page
|
|
||||||
======================= */
|
|
||||||
export default function RedeemPage() {
|
export default function RedeemPage() {
|
||||||
const params = useParams<{ transactionId: string }>();
|
const params = useParams<{ transactionId: string }>();
|
||||||
const search = useSearchParams();
|
const search = useSearchParams();
|
||||||
const transactionId = params?.transactionId ?? search?.get("transactionId") ?? "";
|
const transactionId = params?.transactionId ?? search?.get("transactionId") ?? "";
|
||||||
|
|
||||||
const [phoneDigits, setPhoneDigits] = useState<string>("");
|
const [phoneDigits, setPhoneDigits] = useState("");
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<RedeemResponse | null>(null);
|
const [result, setResult] = useState<RedeemResponse | null>(null);
|
||||||
|
|
||||||
// Validation message
|
const idemRef = useRef<string | null>(null);
|
||||||
const phoneError: string | null = useMemo(() => {
|
|
||||||
|
const phoneError = useMemo(() => {
|
||||||
if (!phoneDigits) return null;
|
if (!phoneDigits) return null;
|
||||||
if (looksLikeCountryCodeFormat(phoneDigits)) {
|
if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
|
||||||
return "กรุณาใส่เบอร์รูปแบบไทย 10 หลัก เช่น 0812345678 (ไม่ต้องใส่ +66)";
|
if (phoneDigits.length > 0 && phoneDigits.length !== 10) return "กรุณากรอกเป็นตัวเลข 10 หลัก";
|
||||||
}
|
if (phoneDigits.length === 10 && !isThaiMobile10(phoneDigits))
|
||||||
if (phoneDigits.length > 0 && phoneDigits.length !== 10) {
|
|
||||||
return "กรุณากรอกเป็นตัวเลข 10 หลัก";
|
|
||||||
}
|
|
||||||
if (phoneDigits.length === 10 && !isStrictThaiMobile10(phoneDigits)) {
|
|
||||||
return "รับเฉพาะเบอร์มือถือไทยที่ขึ้นต้นด้วย 06, 08 หรือ 09 เท่านั้น";
|
return "รับเฉพาะเบอร์มือถือไทยที่ขึ้นต้นด้วย 06, 08 หรือ 09 เท่านั้น";
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}, [phoneDigits]);
|
}, [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<HTMLInputElement>) {
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const onlyDigits = toAsciiDigitsOnly(e.target.value).slice(0, 10);
|
setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
|
||||||
setPhoneDigits(onlyDigits);
|
|
||||||
}
|
}
|
||||||
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
|
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
|
||||||
const ie = e.nativeEvent as InputEvent;
|
const nativeEvt = e.nativeEvent as unknown;
|
||||||
const data = (ie && "data" in ie ? ie.data : undefined) ?? "";
|
const data =
|
||||||
if (!data) return;
|
nativeEvt && typeof nativeEvt === "object" && "data" in (nativeEvt as InputEvent)
|
||||||
if (!/^[0-9๐-๙]+$/.test(data)) e.preventDefault();
|
? (nativeEvt as InputEvent).data
|
||||||
|
: null;
|
||||||
|
if (data && !/^[0-9๐-๙]+$/.test(data)) e.preventDefault();
|
||||||
}
|
}
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
const allow = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight", "Home", "End"];
|
const allow = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight", "Home", "End"];
|
||||||
if (allow.includes(e.key)) return;
|
if (allow.includes(e.key) || /^[0-9]$/.test(e.key)) return;
|
||||||
if (/^[0-9]$/.test(e.key)) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = e.clipboardData.getData("text") ?? "";
|
const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? "");
|
||||||
const onlyDigits = toAsciiDigitsOnly(text);
|
|
||||||
if (!onlyDigits) return;
|
if (!onlyDigits) return;
|
||||||
setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10));
|
setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent) {
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!transactionId || !phoneValid || loading) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tenantKey = getTenantKeyFromHost();
|
const tenantKey = resolveTenantKeyFromHost();
|
||||||
const idempotencyKey = uuidv4();
|
idemRef.current = genIdempotencyKey();
|
||||||
|
|
||||||
const data = await redeemPoints({
|
const payload: RedeemRequest = {
|
||||||
tenantKey,
|
|
||||||
transactionId,
|
transactionId,
|
||||||
phoneDigits10: phoneDigits,
|
contact: { phone: phoneDigits },
|
||||||
idempotencyKey,
|
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);
|
setResult(data);
|
||||||
setMessage("สะสมคะแนนสำเร็จ");
|
setMessage("สะสมคะแนนสำเร็จ");
|
||||||
} catch (err: unknown) {
|
} catch (ex: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้";
|
const err = ex as Error;
|
||||||
setError(msg);
|
setError(err.message ?? "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
idemRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +114,7 @@ export default function RedeemPage() {
|
|||||||
Transaction: <span className="font-mono">{transactionId || "—"}</span>
|
Transaction: <span className="font-mono">{transactionId || "—"}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Card — เรียบแต่เนี๊ยบขึ้น */}
|
{/* Card */}
|
||||||
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
|
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
|
||||||
{!transactionId && (
|
{!transactionId && (
|
||||||
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
@@ -197,14 +124,10 @@ export default function RedeemPage() {
|
|||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-5">
|
<form onSubmit={onSubmit} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-800">
|
<label className="block text-sm font-medium text-neutral-800">เบอร์โทรศัพท์</label>
|
||||||
เบอร์โทรศัพท์
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
className={`mt-1 h-12 w-full rounded-lg border px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 ${
|
className={`mt-1 h-12 w-full rounded-lg border px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 ${
|
||||||
phoneError
|
phoneError ? "border-red-400 focus:ring-red-200" : "border-neutral-300 focus:ring-neutral-200"
|
||||||
? "border-red-400 focus:ring-red-200"
|
|
||||||
: "border-neutral-300 focus:ring-neutral-200"
|
|
||||||
}`}
|
}`}
|
||||||
placeholder="เช่น 0812345678"
|
placeholder="เช่น 0812345678"
|
||||||
value={phoneDigits}
|
value={phoneDigits}
|
||||||
@@ -241,7 +164,6 @@ export default function RedeemPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Banners */}
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
|
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
|
||||||
สะสมคะแนนสำเร็จ
|
สะสมคะแนนสำเร็จ
|
||||||
|
|||||||
145
src/app/api/auth/login/route.ts
Normal file
145
src/app/api/auth/login/route.ts
Normal file
@@ -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<T extends Record<string, unknown>>(obj: T): T {
|
||||||
|
try {
|
||||||
|
if (!obj || typeof obj !== "object") return obj;
|
||||||
|
const clone = { ...obj } as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/api/auth/refresh/route.ts
Normal file
86
src/app/api/auth/refresh/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/crm/redeem/route.ts
Normal file
47
src/app/api/crm/redeem/route.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/api/proxy/[...path]/route.ts
Normal file
53
src/app/api/proxy/[...path]/route.ts
Normal file
@@ -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<string, string>) {
|
||||||
|
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<string, string>)["Content-Type"] = ct;
|
||||||
|
(init.headers as Record<string, string>)["Accept"] = req.headers.get("accept") ?? "application/json";
|
||||||
|
} else {
|
||||||
|
(init.headers as Record<string, string>)["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"); }
|
||||||
160
src/app/component/layout/AppShell.tsx
Normal file
160
src/app/component/layout/AppShell.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex min-h-screen bg-gradient-to-b from-neutral-50 to-white text-neutral-900">
|
||||||
|
{/* Sidebar (desktop) */}
|
||||||
|
<aside
|
||||||
|
className={[
|
||||||
|
"hidden md:flex sticky top-0 h-screen flex-col border-r border-neutral-200/70 bg-white/70 backdrop-blur",
|
||||||
|
"transition-[width] duration-300 ease-out",
|
||||||
|
sidebarOpen ? "w-72" : "w-20",
|
||||||
|
"shadow-[inset_-1px_0_0_rgba(0,0,0,0.03)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="h-16 flex items-center px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-9 w-9 rounded-2xl bg-neutral-900 text-white grid place-items-center font-semibold shadow-sm">
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="font-semibold tracking-tight">EOP</div>
|
||||||
|
<div className="text-[11px] text-neutral-500 -mt-0.5">Operations</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="mx-4 mb-2 h-px bg-gradient-to-r from-transparent via-neutral-200/70 to-transparent" />
|
||||||
|
|
||||||
|
{/* Tenant pill */}
|
||||||
|
<div className="px-4">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"rounded-2xl border border-neutral-200/70 bg-white/70 shadow-sm backdrop-blur-sm",
|
||||||
|
"px-3 py-2 flex items-center gap-2",
|
||||||
|
].join(" ")}
|
||||||
|
title="Current tenant"
|
||||||
|
>
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500/90 shadow-[0_0_0_3px_rgba(16,185,129,0.12)]" />
|
||||||
|
{sidebarOpen ? (
|
||||||
|
<span className="truncate text-xs text-neutral-700">{user?.tenantKey ?? "—"}</span>
|
||||||
|
) : (
|
||||||
|
<span className="sr-only">{user?.tenantKey ?? "—"}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="mt-4 flex-1 space-y-1 px-2">
|
||||||
|
{nav.map((item) => {
|
||||||
|
const active = isActive(item);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={[
|
||||||
|
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all",
|
||||||
|
active ? "bg-neutral-900 text-white shadow-sm" : "text-neutral-700 hover:bg-neutral-100/80",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"grid place-items-center rounded-lg",
|
||||||
|
active ? "bg-white/20 text-white" : "text-neutral-500 group-hover:text-neutral-700",
|
||||||
|
"h-8 w-8",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{item.icon ?? <span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{sidebarOpen && <span className="truncate">{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Subtle footer in sidebar */}
|
||||||
|
<div className="px-4 pb-4 pt-2 text-[11px] text-neutral-400">
|
||||||
|
<div className="rounded-xl bg-neutral-50 border border-neutral-200/70 px-3 py-2">v1.0 • {year}</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<div className="flex min-h-screen flex-1 flex-col">
|
||||||
|
{/* Topbar */}
|
||||||
|
<header className="sticky top-0 z-40 border-b border-neutral-200/70 bg-white/70 backdrop-blur">
|
||||||
|
<div className="mx-auto flex h-16 max-w-screen-2xl items-center justify-between px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen((v) => !v)}
|
||||||
|
className="md:hidden rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-sm shadow-sm hover:bg-neutral-100/80"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
เมนู
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-2 text-sm text-neutral-500 sm:flex">
|
||||||
|
<span className="font-medium text-neutral-800">Dashboard</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="hidden md:inline">Overview</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 rounded-2xl border border-neutral-200/70 bg-white/70 px-2.5 py-1.5 shadow-sm">
|
||||||
|
<div className="grid h-7 w-7 place-items-center rounded-xl bg-neutral-900 text-[11px] font-semibold text-white shadow-sm">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="hidden leading-tight sm:block">
|
||||||
|
<div className="text-xs font-medium text-neutral-800">{user?.email ?? "—"}</div>
|
||||||
|
<div className="text-[11px] text-neutral-500 -mt-0.5">{user?.tenantKey ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main id="main" className="mx-auto w-full max-w-screen-2xl flex-1 p-4 sm:p-6">
|
||||||
|
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-4 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)] sm:p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-auto border-t border-neutral-200/70 bg-white/60 backdrop-blur">
|
||||||
|
<div className="mx-auto flex h-12 max-w-screen-2xl items-center justify-between px-4 text-xs text-neutral-500">
|
||||||
|
<span>© {year} EOP</span>
|
||||||
|
<span className="hidden sm:inline">Built for enterprise operations</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/page.tsx
105
src/app/page.tsx
@@ -1,103 +1,2 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
export default function Home() { redirect("/login"); }
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
15
src/app/providers.tsx
Normal file
15
src/app/providers.tsx
Normal file
@@ -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 (
|
||||||
|
// <QueryClientProvider client={client}>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
// </QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/lib/config.ts
Normal file
6
src/lib/config.ts
Normal file
@@ -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";
|
||||||
4
src/lib/idempotency.ts
Normal file
4
src/lib/idempotency.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
16
src/lib/nav.ts
Normal file
16
src/lib/nav.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
17
src/lib/phone.ts
Normal file
17
src/lib/phone.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const mapTH: Record<string, string> = {
|
||||||
|
"๐":"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);
|
||||||
|
}
|
||||||
166
src/lib/server-auth.ts
Normal file
166
src/lib/server-auth.ts
Normal file
@@ -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 "<empty>";
|
||||||
|
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<UserSession | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
47
src/lib/server-tenant.ts
Normal file
47
src/lib/server-tenant.ts
Normal file
@@ -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";
|
||||||
|
}
|
||||||
9
src/lib/tenant.ts
Normal file
9
src/lib/tenant.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
2
src/types/auth/index.ts
Normal file
2
src/types/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./user";
|
||||||
|
export * from "./upstream";
|
||||||
19
src/types/auth/upstream.ts
Normal file
19
src/types/auth/upstream.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
18
src/types/auth/user.ts
Normal file
18
src/types/auth/user.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
1
src/types/crm/index.ts
Normal file
1
src/types/crm/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./loyalty";
|
||||||
13
src/types/crm/loyalty.ts
Normal file
13
src/types/crm/loyalty.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type RedeemRequest = {
|
||||||
|
transactionId: string;
|
||||||
|
contact: { phone: string };
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RedeemResponse = {
|
||||||
|
balance?: number;
|
||||||
|
voucherCode?: string;
|
||||||
|
ledgerEntryId?: string;
|
||||||
|
redeemed?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user