feat: add Ina Trading portal flows and API integration
This commit is contained in:
@ -1,7 +1,22 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
unoptimized: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "be.inatrading.co.id",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "203.175.11.191",
|
||||
},
|
||||
],
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
231
package-lock.json
generated
231
package-lock.json
generated
@ -8,7 +8,11 @@
|
||||
"name": "ina-trading-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"axios": "^1.15.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"next": "16.2.3",
|
||||
"nodemailer": "^8.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
@ -1019,6 +1023,12 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@ -1621,12 +1631,20 @@
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -2485,6 +2503,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@ -2511,6 +2535,17 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@ -2621,7 +2656,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@ -2701,6 +2735,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@ -2721,6 +2764,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@ -2822,7 +2877,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@ -2879,6 +2933,24 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@ -2906,7 +2978,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@ -3018,7 +3089,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -3028,7 +3098,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -3066,7 +3135,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@ -3079,7 +3147,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@ -3676,6 +3743,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@ -3692,11 +3779,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@ -3757,7 +3859,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@ -3782,7 +3883,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@ -3870,7 +3970,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -3942,7 +4041,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -3955,7 +4053,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@ -3971,7 +4068,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@ -4049,6 +4145,30 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.5.1",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@ -4922,6 +5042,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@ -4966,7 +5098,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -4996,6 +5127,27 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@ -5023,7 +5175,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@ -5174,6 +5325,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -5485,6 +5645,15 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@ -5544,6 +5713,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@ -5960,6 +6150,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@ -6438,7 +6634,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
||||
@ -9,7 +9,11 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"axios": "^1.15.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"next": "16.2.3",
|
||||
"nodemailer": "^8.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
|
||||
7
public/favicon.svg
Normal file
7
public/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="white" rx="8"/>
|
||||
<!-- INA in red -->
|
||||
<text x="50" y="58" font-family="Arial Black, Arial" font-weight="900" font-size="46" fill="#b7131a" text-anchor="middle" letter-spacing="-1">INA</text>
|
||||
<!-- TRADING in navy -->
|
||||
<text x="50" y="82" font-family="Georgia, Times New Roman, serif" font-weight="700" font-size="16" fill="#1a237e" text-anchor="middle" letter-spacing="1">TRADING</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 500 B |
BIN
public/ina_logo.png
Executable file
BIN
public/ina_logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
15
public/logo.svg
Normal file
15
public/logo.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2000 520" width="2000" height="520" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Ina Trading</title>
|
||||
<desc id="desc">INA TRADING logo with subtitle Digital Security Technology by PERURI.</desc>
|
||||
<rect width="2000" height="520" fill="#ffffff"/>
|
||||
<rect x="20" y="100" width="820" height="320" fill="#000000"/>
|
||||
<text x="28" y="420" fill="#cf0000" font-family="Arial Black, Arial, sans-serif" font-size="420" font-weight="900" letter-spacing="-22">INA</text>
|
||||
<text x="832" y="345" fill="#1f275f" font-family="Georgia, Times New Roman, serif" font-size="248" font-weight="700" letter-spacing="-4">TRADING</text>
|
||||
<rect x="860" y="378" width="520" height="6" fill="#1f275f"/>
|
||||
<text x="860" y="420" fill="#1f275f" font-family="Arial, Helvetica, sans-serif" font-size="58" font-style="italic">Digital Security Technology by</text>
|
||||
<rect x="1634" y="332" width="242" height="88" fill="#2f3396"/>
|
||||
<path d="M1740 330 L1790 330 L1820 364 L1788 364 L1768 348 L1740 348 Z" fill="#d67aff"/>
|
||||
<path d="M1820 364 L1874 364 L1874 420 L1838 392 L1828 380 Z" fill="#c668ff"/>
|
||||
<path d="M1786 366 L1818 366 L1838 390 L1810 406 L1780 388 Z" fill="#f296ff"/>
|
||||
<path d="M1808 406 L1842 392 L1848 420 L1816 420 Z" fill="#8a34ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
128
src/app/(auth)/account-not-found/page.tsx
Normal file
128
src/app/(auth)/account-not-found/page.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
function AccountNotFoundContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email") || "";
|
||||
const { t } = useLanguage();
|
||||
const a = t.auth.accountNotFound;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col md:flex-row min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="hidden md:flex md:w-1/2 lg:w-3/5 bg-secondary relative items-end p-12 lg:p-20 overflow-hidden">
|
||||
<div className="absolute inset-0 z-0 bg-gradient-to-t from-secondary via-secondary/80 to-secondary/60" />
|
||||
<div className="relative z-10 max-w-xl">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<span className="h-1 w-12 bg-primary" />
|
||||
<span className="text-on-secondary font-headline font-bold uppercase tracking-widest text-xs">
|
||||
{a.editorialIntelligence}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-on-secondary font-headline text-5xl lg:text-7xl font-black leading-none tracking-tighter mb-6 whitespace-pre-line">
|
||||
{a.tradePrecision}
|
||||
</h1>
|
||||
<p className="text-on-secondary/80 text-xl font-medium max-w-md leading-relaxed">
|
||||
{a.tradeSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="flex-1 bg-surface-container-lowest flex flex-col p-8 md:p-12 lg:p-20 relative md:overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-16">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageToggle />
|
||||
<Link
|
||||
href="/login"
|
||||
className="hidden sm:flex text-secondary font-semibold hover:text-primary transition-colors items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">help_outline</span>
|
||||
{a.helpLink}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center max-w-md w-full mx-auto">
|
||||
<div className="mb-8 h-16 w-16 bg-error-container rounded-xl flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-error text-3xl">person_off</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<h2 className="font-headline text-4xl font-extrabold tracking-tight text-on-surface mb-3">
|
||||
{a.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 p-3 bg-surface-container-low rounded-lg border-l-4 border-tertiary">
|
||||
<p className="text-on-surface-variant font-medium truncate">{email}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-tertiary font-bold hover:underline ml-auto flex items-center gap-1 shrink-0"
|
||||
>
|
||||
{a.change}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/register?email=${encodeURIComponent(email)}`}
|
||||
className="block w-full text-center signature-cta-gradient text-on-primary font-headline font-bold py-5 rounded-xl shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all text-xl uppercase tracking-wider"
|
||||
>
|
||||
{a.createAccount}
|
||||
</Link>
|
||||
|
||||
<div className="relative py-4">
|
||||
<div aria-hidden="true" className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-outline-variant/30" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-surface-container-lowest px-4 text-sm font-medium text-outline">
|
||||
{t.common.or}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-3 w-full border-2 border-outline-variant/30 text-on-surface font-bold py-4 rounded-xl hover:bg-surface-container transition-colors group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-secondary group-hover:text-primary transition-colors">
|
||||
login
|
||||
</span>
|
||||
{a.loginOther}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="mt-auto pt-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-4">
|
||||
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
{t.common.privacy}
|
||||
</a>
|
||||
<a href="#" className="text-outline text-xs font-semibold hover:text-on-surface uppercase tracking-widest transition-colors">
|
||||
{t.common.terms}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-outline/60 max-w-sm">{a.disclaimer}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountNotFoundPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AccountNotFoundContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
152
src/app/(auth)/forgot-password/page.tsx
Normal file
152
src/app/(auth)/forgot-password/page.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useLanguage();
|
||||
const f = t.auth.forgotPassword;
|
||||
|
||||
const [contact, setContact] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!contact.trim()) return;
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="min-h-screen flex flex-col md:flex-row bg-surface overflow-x-hidden">
|
||||
<section className="hidden md:flex md:w-1/2 lg:w-3/5 relative overflow-hidden flex-col justify-end p-16 bg-primary">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.14),transparent_25%),linear-gradient(160deg,#b7131a_0%,#8d1116_55%,#57070a_100%)]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-1 w-12 bg-white rounded-full" />
|
||||
<span className="font-headline font-extrabold text-white tracking-widest text-sm">
|
||||
{f.securityFirst}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-6xl lg:text-7xl font-headline font-black text-white tracking-tighter leading-none whitespace-pre-line">
|
||||
{f.heroTitle}
|
||||
</h1>
|
||||
<p className="text-xl text-white/80 max-w-md font-light leading-relaxed">
|
||||
{f.heroSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-12 pt-8 border-t border-white/20 flex gap-12">
|
||||
<div>
|
||||
<span className="block text-white font-headline font-bold text-3xl">99.9%</span>
|
||||
<span className="text-white/60 text-xs font-label uppercase tracking-widest">{f.uptime}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-white font-headline font-bold text-3xl">24/7</span>
|
||||
<span className="text-white/60 text-xs font-label uppercase tracking-widest">{f.fraudMonitoring}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex-1 bg-surface flex flex-col justify-center items-center p-8 md:p-16 lg:p-24 relative">
|
||||
<div className="absolute top-12 left-8 md:static md:mb-20 md:self-start flex items-center gap-4">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
</div>
|
||||
|
||||
<div className="absolute top-12 right-8">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md space-y-10">
|
||||
<header className="space-y-4">
|
||||
<h2 className="text-4xl font-headline font-extrabold text-on-surface tracking-tight">
|
||||
{f.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant leading-relaxed text-lg">{f.subtitle}</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
{submitted && (
|
||||
<div className="rounded-xl border border-tertiary/10 bg-tertiary/5 p-4 text-sm text-on-tertiary-fixed-variant">
|
||||
{f.apiNotReady} <strong>{contact}</strong>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative group">
|
||||
<label
|
||||
className="block text-xs font-headline font-bold uppercase tracking-wider text-outline mb-2 group-focus-within:text-primary transition-colors"
|
||||
htmlFor="recovery-contact"
|
||||
>
|
||||
{f.emailOrPhone}
|
||||
</label>
|
||||
<div className="flex items-center rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-4 transition-all group-focus-within:border-primary group-focus-within:bg-surface-container-lowest">
|
||||
<span className="material-symbols-outlined text-outline group-focus-within:text-primary mr-3">
|
||||
alternate_email
|
||||
</span>
|
||||
<input
|
||||
id="recovery-contact"
|
||||
name="recovery-contact"
|
||||
type="text"
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
placeholder="name@company.com"
|
||||
required
|
||||
className="w-full bg-transparent border-none p-0 text-lg font-medium text-on-surface placeholder:text-outline/50 focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-on-surface-variant/70 italic">
|
||||
{/* privacy note kept short */}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-5 px-8 rounded-xl editorial-gradient text-on-primary font-headline font-bold text-xl tracking-tight magazine-shadow active:scale-[0.98] transition-all hover:brightness-110 flex items-center justify-center gap-3"
|
||||
>
|
||||
{f.submit}
|
||||
<span className="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="pt-8 border-t border-outline-variant/30 flex flex-col gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center gap-2 text-secondary font-semibold hover:text-primary transition-colors group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl group-hover:-translate-x-1 transition-transform">
|
||||
chevron_left
|
||||
</span>
|
||||
{f.backToLogin}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 p-4 rounded-xl bg-tertiary/5 border border-tertiary/10">
|
||||
<span className="material-symbols-outlined text-tertiary">contact_support</span>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{f.havingTrouble}{" "}
|
||||
<a href="#" className="text-tertiary font-bold hover:underline">{f.supportLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 right-0 w-64 h-64 bg-primary-container/5 rounded-full blur-3xl -z-10 pointer-events-none" />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
<button className="bg-surface-container-lowest text-secondary p-4 rounded-full magazine-shadow border border-outline-variant/20 hover:bg-secondary hover:text-on-secondary transition-all active:scale-95 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined">help_center</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
231
src/app/(auth)/login/page.tsx
Normal file
231
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const authFieldWrapperClass =
|
||||
"relative rounded-xl border border-outline-variant/60 bg-surface-container-high px-0 transition-all duration-300 focus-within:border-primary focus-within:bg-surface-container-lowest";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const l = t.auth.login;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 404 && data.error === "ACCOUNT_NOT_FOUND") {
|
||||
router.push(`/account-not-found?email=${encodeURIComponent(email)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || l.errorGeneric);
|
||||
return;
|
||||
}
|
||||
|
||||
if (remember) {
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("role", data.role);
|
||||
} else {
|
||||
sessionStorage.setItem("token", data.token);
|
||||
sessionStorage.setItem("role", data.role);
|
||||
}
|
||||
|
||||
if (data.role === "admin") {
|
||||
router.push("/admin/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.role === "seller") {
|
||||
// Check seller profile completeness
|
||||
try {
|
||||
const profileRes = await fetch("/api/seller/profile", {
|
||||
headers: { "x-auth-token": data.token },
|
||||
});
|
||||
const profileData = await profileRes.json();
|
||||
const profile = profileData?.data || profileData;
|
||||
|
||||
const isIncomplete =
|
||||
!profile?.storeName ||
|
||||
!profile?.biography ||
|
||||
!profile?.sellerImageUrl;
|
||||
|
||||
if (isIncomplete || data.onboardingRequired) {
|
||||
router.push("/onboarding/business");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If profile check fails, still proceed to dashboard
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError(l.errorConnection);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="hidden lg:flex lg:w-1/2 bg-primary-editorial relative overflow-hidden flex-col justify-end p-16">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute -top-24 -left-24 w-96 h-96 bg-white/10 rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-48 -right-48 w-[40rem] h-[40rem] bg-black/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<h1 className="text-7xl font-extrabold text-white font-headline leading-[0.95] tracking-tighter mb-8 max-w-lg whitespace-pre-line">
|
||||
{l.heroTitle}
|
||||
</h1>
|
||||
<p className="text-white/80 text-xl font-medium max-w-md leading-relaxed">
|
||||
{l.heroSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="w-full lg:w-1/2 flex items-center justify-center p-8 lg:p-24 bg-surface-container-lowest md:overflow-y-auto">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile Logo + Language Toggle */}
|
||||
<div className="lg:hidden mb-12 flex justify-between items-center">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
{/* Desktop language toggle */}
|
||||
<div className="hidden lg:flex items-start justify-between mb-10">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={180} height={54} priority />
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
<header className="mb-12">
|
||||
<h2 className="text-4xl font-extrabold text-on-surface font-headline tracking-tight mb-2">
|
||||
{l.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant font-medium">{l.subtitle}</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-outline mb-2 block">
|
||||
{l.emailOrPhone}
|
||||
</label>
|
||||
<div className={authFieldWrapperClass}>
|
||||
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline-variant">
|
||||
person
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@business.com"
|
||||
required
|
||||
className="w-full rounded-xl border-none bg-transparent py-4 pl-12 pr-4 font-medium placeholder:text-outline-variant focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-outline block">
|
||||
Password
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-xs font-bold text-tertiary hover:underline">
|
||||
{l.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={authFieldWrapperClass}>
|
||||
<span className="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline-variant">
|
||||
lock
|
||||
</span>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full rounded-xl border-none bg-transparent py-4 pl-12 pr-12 font-medium placeholder:text-outline-variant focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-outline-variant hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-outline-variant text-primary focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="remember" className="text-sm font-medium text-on-surface-variant">
|
||||
{l.rememberDevice}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary text-white font-bold py-5 rounded-xl shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all duration-200 text-lg disabled:opacity-70 disabled:scale-100"
|
||||
>
|
||||
{loading ? l.submitting : l.submit}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="mt-12 text-center">
|
||||
<p className="text-on-surface-variant font-medium">
|
||||
{l.noAccount}{" "}
|
||||
<Link href="/register" className="text-primary font-bold hover:underline ml-1">
|
||||
{l.registerFree}
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
103
src/app/(auth)/register/complete/page.tsx
Normal file
103
src/app/(auth)/register/complete/page.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
export default function CompleteRegisterPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const c = t.auth.complete;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const raw = sessionStorage.getItem("registerData");
|
||||
const otpVerified = sessionStorage.getItem("otpVerified");
|
||||
if (!raw || otpVerified !== "true") {
|
||||
router.replace("/register");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
async function handleComplete() {
|
||||
const raw = sessionStorage.getItem("registerData");
|
||||
if (!raw) {
|
||||
setError(c.noData);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const registerData = JSON.parse(raw);
|
||||
const res = await fetch("/api/auth/finalize-register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: registerData.role, registerData }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data?.error || c.registerFail);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("registerData");
|
||||
sessionStorage.removeItem("otpVerified");
|
||||
sessionStorage.removeItem("otpVerifiedEmail");
|
||||
router.push("/login");
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface p-6">
|
||||
<div className="w-full max-w-xl rounded-2xl bg-surface-container-lowest p-10 shadow-lg">
|
||||
<div className="flex justify-end mb-4">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 inline-flex items-center rounded-full bg-primary/10 px-4 py-1.5 text-xs font-black uppercase tracking-widest text-primary">
|
||||
{c.finalStep}
|
||||
</div>
|
||||
<h1 className="font-headline text-4xl font-black tracking-tight text-on-surface">
|
||||
{c.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-on-surface-variant">{c.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-6 rounded-xl bg-error-container p-4 text-sm font-medium text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleComplete}
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-primary px-6 py-4 text-lg font-bold text-white shadow-lg shadow-primary/20 transition-all hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{loading ? c.submitting : c.submit}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/register"
|
||||
className="block text-center text-sm font-semibold text-on-surface-variant hover:text-primary"
|
||||
>
|
||||
{c.backToRegister}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
337
src/app/(auth)/register/page.tsx
Normal file
337
src/app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
type Role = "seller" | "buyer";
|
||||
|
||||
const authInputClass =
|
||||
"w-full rounded-xl border border-outline-variant/60 bg-surface-container-highest px-4 py-3 text-on-surface placeholder:text-surface-dim focus:border-primary focus:bg-surface-container-lowest focus:outline-none transition-all duration-300";
|
||||
|
||||
const authInputWithIconClass =
|
||||
"w-full rounded-xl border border-outline-variant/60 bg-surface-container-highest px-4 py-3 pr-10 text-on-surface placeholder:text-surface-dim focus:border-primary focus:bg-surface-container-lowest focus:outline-none transition-all duration-300";
|
||||
|
||||
function RegisterContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const emailFromQuery = searchParams.get("email") || "";
|
||||
const { t } = useLanguage();
|
||||
const r = t.auth.register;
|
||||
|
||||
const [role, setRole] = useState<Role>("seller");
|
||||
const [email, setEmail] = useState(emailFromQuery);
|
||||
const [name, setName] = useState("");
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(r.passwordMismatch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError(r.passwordTooShort);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/send-otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || r.otpError);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(
|
||||
"registerData",
|
||||
JSON.stringify({ name, mobile, password, role, email })
|
||||
);
|
||||
|
||||
router.push(`/register/verify?email=${encodeURIComponent(email)}`);
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col md:flex-row min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="relative w-full md:w-5/12 lg:w-1/2 min-h-[280px] md:h-full flex items-end p-8 lg:p-16 overflow-hidden bg-on-surface">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/80 via-primary/60 to-primary-container/40" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-on-surface via-transparent to-transparent opacity-80" />
|
||||
</div>
|
||||
<div className="relative z-10 max-w-lg">
|
||||
<h1 className="text-white font-headline text-5xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] mb-6 whitespace-pre-line">
|
||||
{r.joinNetwork}
|
||||
</h1>
|
||||
<p className="text-surface-container-low text-lg lg:text-xl font-medium leading-relaxed opacity-90 max-w-md">
|
||||
{r.joinSubtitle}
|
||||
</p>
|
||||
<div className="mt-12 pt-12 border-t border-white/20 flex gap-12">
|
||||
<div>
|
||||
<div className="text-white font-headline text-4xl font-bold">98%</div>
|
||||
<div className="text-white/60 text-sm uppercase tracking-widest font-bold mt-1">{r.successRate}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-headline text-4xl font-bold">24/7</div>
|
||||
<div className="text-white/60 text-sm uppercase tracking-widest font-bold mt-1">{r.marketPulse}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="w-full md:w-7/12 lg:w-1/2 bg-surface-container-lowest flex flex-col overflow-y-auto">
|
||||
<div className="w-full h-1.5 bg-primary" />
|
||||
<div className="w-full max-w-2xl mx-auto p-8 lg:p-10 xl:p-12 flex-1 flex flex-col">
|
||||
<div className="mb-8">
|
||||
<div className="mb-8 flex items-start justify-between gap-4">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={180} height={54} priority />
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<h2 className="font-headline text-3xl font-extrabold tracking-tight text-on-surface mb-2 text-center">
|
||||
{r.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant font-medium text-center">{r.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Selector */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline">
|
||||
{r.registerAs}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("seller")}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
|
||||
role === "seller"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-outline-variant/30 text-on-surface-variant hover:border-outline/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-2xl"
|
||||
style={{ fontVariationSettings: role === "seller" ? "'FILL' 1" : "'FILL' 0" }}
|
||||
>
|
||||
storefront
|
||||
</span>
|
||||
<span className="text-sm font-bold">Seller</span>
|
||||
<span className="text-[10px] text-center opacity-70 leading-tight">
|
||||
{r.sellerDesc}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("buyer")}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
|
||||
role === "buyer"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-outline-variant/30 text-on-surface-variant hover:border-outline/40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-2xl"
|
||||
style={{ fontVariationSettings: role === "buyer" ? "'FILL' 1" : "'FILL' 0" }}
|
||||
>
|
||||
shopping_bag
|
||||
</span>
|
||||
<span className="text-sm font-bold">Buyer</span>
|
||||
<span className="text-[10px] text-center opacity-70 leading-tight">
|
||||
{r.buyerDesc}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="email">
|
||||
{r.email}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@business.com"
|
||||
required
|
||||
readOnly={!!emailFromQuery}
|
||||
className={`${authInputClass} ${
|
||||
emailFromQuery ? "bg-surface-container text-on-surface-variant cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
{emailFromQuery && (
|
||||
<p className="text-[10px] text-on-surface-variant mt-1">{r.emailFromPrevious}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="name">
|
||||
{r.fullName}
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
className={authInputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="mobile">
|
||||
{r.phone}
|
||||
</label>
|
||||
<input
|
||||
id="mobile"
|
||||
type="tel"
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(e.target.value)}
|
||||
placeholder="+62 812 3456 789"
|
||||
required
|
||||
className={authInputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="password">
|
||||
{r.password}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={r.passwordPlaceholder}
|
||||
required
|
||||
className={authInputWithIconClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-0 top-3 text-outline-variant hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-bold uppercase tracking-widest text-outline" htmlFor="confirm-password">
|
||||
{r.confirmPassword}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={r.confirmPasswordPlaceholder}
|
||||
required
|
||||
className={authInputWithIconClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-0 top-3 text-outline-variant hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">
|
||||
{showConfirm ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="signature-cta-gradient w-full py-4 text-on-primary font-headline font-bold text-lg rounded-xl magazine-shadow hover:scale-[1.02] active:scale-95 transition-all duration-200 disabled:opacity-70 disabled:scale-100"
|
||||
>
|
||||
{loading ? r.submitting : r.submit}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-on-surface-variant font-medium">
|
||||
{r.haveAccount}{" "}
|
||||
<Link href="/login" className="text-tertiary font-bold hover:text-primary transition-colors ml-1">
|
||||
{r.signIn}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Step Progress */}
|
||||
<div className="mt-10 flex justify-between items-center bg-surface-container-low p-4 rounded-xl">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-1.5 rounded-full bg-primary" />
|
||||
<div className="w-8 h-1.5 rounded-full bg-outline-variant" />
|
||||
<div className="w-8 h-1.5 rounded-full bg-outline-variant" />
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{r.stepOf}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pb-10 text-center">
|
||||
<p className="text-[10px] text-outline leading-tight uppercase tracking-tighter">
|
||||
{r.termsAgreement}{" "}
|
||||
<a href="#" className="underline">{t.common.terms}</a> {r.and}{" "}
|
||||
<a href="#" className="underline">{t.common.privacy}</a> {r.inaTrading}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RegisterContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
321
src/app/(auth)/register/verify/page.tsx
Normal file
321
src/app/(auth)/register/verify/page.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState, useRef, Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
function VerifyContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams.get("email") || "";
|
||||
const { t } = useLanguage();
|
||||
const v = t.auth.verify;
|
||||
|
||||
const [otp, setOtp] = useState(["", "", "", "", "", ""]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [errorStep, setErrorStep] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRefs.current[0]?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [countdown]);
|
||||
|
||||
function handleOtpChange(index: number, value: string) {
|
||||
if (!/^\d*$/.test(value)) return;
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = value.slice(-1);
|
||||
setOtp(newOtp);
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
|
||||
if (!pasted) return;
|
||||
const newOtp = [...otp];
|
||||
pasted.split("").forEach((char, i) => { if (i < 6) newOtp[i] = char; });
|
||||
setOtp(newOtp);
|
||||
const nextIndex = Math.min(pasted.length, 5);
|
||||
inputRefs.current[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setErrorStep("");
|
||||
const otpCode = otp.join("");
|
||||
|
||||
if (otpCode.length < 6) {
|
||||
setError(v.otpTooShort);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData = sessionStorage.getItem("registerData");
|
||||
if (!rawData) {
|
||||
setError(v.noData);
|
||||
return;
|
||||
}
|
||||
|
||||
const { role } = JSON.parse(rawData);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/verify-otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, otp: otpCode }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const backendMessage = data?.error || data?.responseDesc || data?.message || v.verifyFail;
|
||||
setError(backendMessage);
|
||||
setErrorStep(data?.step || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedData = JSON.parse(rawData);
|
||||
|
||||
if (role === "seller") {
|
||||
const registerData = { ...parsedData, email, otpVerified: true };
|
||||
|
||||
const registerRes = await fetch("/api/auth/finalize-register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: "seller", registerData }),
|
||||
});
|
||||
|
||||
const registerResult = await registerRes.json();
|
||||
|
||||
if (!registerRes.ok) {
|
||||
setError(registerResult?.error || v.registerFail);
|
||||
setErrorStep(registerResult?.step || "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerResult?.token) {
|
||||
sessionStorage.setItem("token", registerResult.token);
|
||||
sessionStorage.setItem("role", "seller");
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("registerData");
|
||||
sessionStorage.removeItem("otpVerified");
|
||||
sessionStorage.removeItem("otpVerifiedEmail");
|
||||
setSuccess(v.successSeller);
|
||||
setTimeout(() => { router.push("/onboarding/business"); }, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem("registerData", JSON.stringify({ ...parsedData, email, otpVerified: true }));
|
||||
sessionStorage.setItem("otpVerified", "true");
|
||||
sessionStorage.setItem("otpVerifiedEmail", email);
|
||||
setSuccess(v.successBuyer);
|
||||
setTimeout(() => { router.push("/register/complete"); }, 1000);
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (countdown > 0) return;
|
||||
setError("");
|
||||
setErrorStep("");
|
||||
setResending(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/send-otp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setOtp(["", "", "", "", "", ""]);
|
||||
inputRefs.current[0]?.focus();
|
||||
setCountdown(60);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || t.common.connectionError);
|
||||
}
|
||||
} catch {
|
||||
setError(t.common.connectionError);
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col md:flex-row min-h-screen md:h-screen md:overflow-hidden">
|
||||
{/* Left Side */}
|
||||
<section className="hidden md:flex md:w-5/12 lg:w-1/2 relative bg-primary overflow-hidden items-center justify-center p-12">
|
||||
<div className="absolute inset-0 z-0 opacity-20 bg-gradient-to-br from-primary-container to-primary" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary via-primary to-primary-container opacity-90 z-10" />
|
||||
<div className="relative z-20 max-w-lg">
|
||||
<h1 className="text-5xl lg:text-7xl font-headline font-extrabold text-on-primary leading-none tracking-tight mb-8 whitespace-pre-line">
|
||||
{v.secureYourFuture}
|
||||
</h1>
|
||||
<div className="h-1 w-24 bg-on-primary mb-8" />
|
||||
<p className="text-on-primary/80 text-xl font-medium leading-relaxed max-w-sm">
|
||||
{v.verifyIdentity}
|
||||
</p>
|
||||
<div className="mt-16 grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<p className="text-on-primary font-bold text-3xl font-headline tracking-tighter">99.9%</p>
|
||||
<p className="text-on-primary/60 text-sm">{v.transactionSecurity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-on-primary font-bold text-3xl font-headline tracking-tighter">256-bit</p>
|
||||
<p className="text-on-primary/60 text-sm">{v.bankLevelEncryption}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right Side */}
|
||||
<section className="flex-1 bg-surface flex items-center justify-center p-6 md:p-12 lg:p-24 relative md:overflow-y-auto">
|
||||
{/* Logo */}
|
||||
<div className="absolute top-8 left-8">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={160} height={48} priority />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<header className="mb-12">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-tertiary/10 rounded-xl mb-6">
|
||||
<span className="material-symbols-outlined text-tertiary" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
mark_email_read
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-4xl font-headline font-extrabold text-on-surface tracking-tight mb-4">
|
||||
{v.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant leading-relaxed">
|
||||
{v.subtitle}{" "}
|
||||
<span className="font-semibold text-on-surface">{email}</span>
|
||||
{v.subtitleSuffix}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="p-3 bg-error-container text-on-error-container rounded-lg text-sm font-medium">
|
||||
<div>{error}</div>
|
||||
{errorStep ? (
|
||||
<div className="mt-1 text-[11px] font-semibold uppercase tracking-wider opacity-70">
|
||||
Step: {errorStep}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-3 bg-tertiary/10 text-tertiary rounded-lg text-sm font-medium">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OTP Grid */}
|
||||
<div className="flex gap-3 justify-between" onPaste={handlePaste}>
|
||||
{otp.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleOtpChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
placeholder="•"
|
||||
className="otp-input w-14 h-16 text-center text-2xl font-bold rounded-xl border-none bg-surface-container-highest text-on-surface transition-all focus:bg-surface-container-lowest focus:ring-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-on-surface-variant">{v.noCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={countdown > 0 || resending}
|
||||
className="text-tertiary font-bold hover:underline transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{resending
|
||||
? v.resending
|
||||
: countdown > 0
|
||||
? `${v.resendCountdown} (${countdown}s)`
|
||||
: v.resend}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otp.join("").length < 6}
|
||||
className="w-full bg-gradient-to-br from-primary to-primary-container text-on-primary font-headline font-bold py-5 px-8 rounded-xl shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.98] transition-all flex items-center justify-center gap-3 disabled:opacity-60 disabled:scale-100"
|
||||
>
|
||||
<span>{loading ? v.submitting : v.submit}</span>
|
||||
{!loading && <span className="material-symbols-outlined text-lg">arrow_forward</span>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="mt-16 pt-8 border-t border-outline-variant/20">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-outline text-sm">shield</span>
|
||||
<span className="text-xs text-on-surface-variant uppercase tracking-widest font-semibold">
|
||||
{v.securityTitle}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant/70 leading-relaxed">{v.securityDesc}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/register"
|
||||
className="absolute top-8 right-8 flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
<span className="hidden md:inline font-label">{t.common.cancel}</span>
|
||||
</Link>
|
||||
|
||||
<div className="absolute top-8 right-24">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
261
src/app/(auth)/reset-password/page.tsx
Normal file
261
src/app/(auth)/reset-password/page.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
|
||||
const passwordFieldWrapperClass =
|
||||
"relative rounded-xl border border-outline-variant/60 bg-surface-container-highest transition-all duration-300 focus-within:border-primary focus-within:bg-surface-container-lowest";
|
||||
|
||||
function getPasswordChecks(password: string) {
|
||||
return {
|
||||
minLength: password.length >= 12,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
number: /\d/.test(password),
|
||||
special: /[^A-Za-z0-9]/.test(password),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const checks = getPasswordChecks(newPassword);
|
||||
const isStrongPassword = Object.values(checks).every(Boolean);
|
||||
const passwordsMatch = newPassword === confirmPassword;
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSubmitted(false);
|
||||
|
||||
if (!isStrongPassword) {
|
||||
setError("Password baru belum memenuhi requirement minimum.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordsMatch) {
|
||||
setError("Konfirmasi password tidak sama.");
|
||||
return;
|
||||
}
|
||||
|
||||
// API reset password belum tersedia.
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-screen w-full bg-background">
|
||||
<div className="hidden lg:flex lg:w-7/12 relative overflow-hidden editorial-gradient flex-col justify-between p-16">
|
||||
<div className="z-10">
|
||||
<Image
|
||||
src="/ina_logo.png"
|
||||
alt="Ina Trading"
|
||||
width={180}
|
||||
height={54}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="z-10 max-w-xl">
|
||||
<h1 className="font-headline text-white text-7xl font-extrabold leading-[1.05] tracking-tight mb-8">
|
||||
Secure. <br />
|
||||
Refined. <br />
|
||||
Absolute.
|
||||
</h1>
|
||||
<p className="text-white/80 text-xl font-medium leading-relaxed">
|
||||
Protecting your financial ecosystem with world-class encryption
|
||||
and institutional-grade security protocols.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="z-10 grid grid-cols-2 gap-8 border-t border-white/20 pt-12">
|
||||
<div>
|
||||
<div className="text-white/60 text-xs font-bold uppercase tracking-widest mb-2">
|
||||
Security Status
|
||||
</div>
|
||||
<div className="text-white text-3xl font-bold font-headline">
|
||||
99.9% Up
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/60 text-xs font-bold uppercase tracking-widest mb-2">
|
||||
Data Protection
|
||||
</div>
|
||||
<div className="text-white text-3xl font-bold font-headline">
|
||||
AES-256
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.15),transparent_28%),linear-gradient(140deg,rgba(0,0,0,0.12),rgba(0,0,0,0.32))]" />
|
||||
<div className="absolute inset-0 opacity-40 mix-blend-overlay bg-[linear-gradient(120deg,transparent_0%,rgba(255,255,255,0.06)_35%,transparent_60%),radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.12),transparent_24%)]" />
|
||||
</div>
|
||||
<div className="absolute -bottom-24 -left-24 w-96 h-96 bg-tertiary/30 blur-[120px] rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-5/12 bg-surface-container-lowest flex flex-col justify-center px-8 sm:px-16 lg:px-24">
|
||||
<div className="max-w-md w-full mx-auto">
|
||||
<div className="lg:hidden mb-12 flex justify-between items-center">
|
||||
<Image
|
||||
src="/ina_logo.png"
|
||||
alt="Ina Trading"
|
||||
width={150}
|
||||
height={45}
|
||||
priority
|
||||
/>
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<div className="hidden lg:flex justify-end mb-4">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
|
||||
<header className="mb-12">
|
||||
<div className="h-1 w-12 bg-primary mb-6" />
|
||||
<h2 className="font-headline text-4xl font-extrabold text-on-surface tracking-tight leading-tight">
|
||||
Reset password
|
||||
</h2>
|
||||
<p className="mt-4 text-on-surface-variant font-medium leading-relaxed">
|
||||
Enter your new credentials to regain access to your trading
|
||||
dashboard.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error/10 bg-error-container p-4 text-sm text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<div className="rounded-xl border border-tertiary/10 bg-tertiary/5 p-4 text-sm text-on-tertiary-fixed-variant">
|
||||
Reset password API belum tersedia. Screen ini sudah siap dan
|
||||
submit reset password berhasil disimulasikan.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 group">
|
||||
<label
|
||||
htmlFor="new-password"
|
||||
className="text-xs font-bold uppercase tracking-widest text-on-surface-variant group-focus-within:text-primary transition-colors"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<div className={passwordFieldWrapperClass}>
|
||||
<input
|
||||
id="new-password"
|
||||
name="new-password"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full rounded-xl bg-transparent border-none py-4 px-4 pr-12 text-on-surface font-medium placeholder:text-outline-variant focus:ring-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword((prev) => !prev)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{showNewPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 group">
|
||||
<label
|
||||
htmlFor="confirm-password"
|
||||
className="text-xs font-bold uppercase tracking-widest text-on-surface-variant group-focus-within:text-primary transition-colors"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div className={passwordFieldWrapperClass}>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full rounded-xl bg-transparent border-none py-4 px-4 pr-12 text-on-surface font-medium placeholder:text-outline-variant focus:ring-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{showConfirmPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-surface-container rounded-lg border-l-4 border-tertiary">
|
||||
<p className="text-xs font-medium text-on-surface-variant leading-relaxed">
|
||||
<span className="text-tertiary font-bold">Requirement:</span>{" "}
|
||||
Minimum 12 characters, including one uppercase letter, one
|
||||
special character, and one numeric value.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] font-medium">
|
||||
<span className={checks.minLength ? "text-tertiary" : "text-outline"}>
|
||||
12+ characters
|
||||
</span>
|
||||
<span className={checks.uppercase ? "text-tertiary" : "text-outline"}>
|
||||
Uppercase letter
|
||||
</span>
|
||||
<span className={checks.number ? "text-tertiary" : "text-outline"}>
|
||||
Numeric value
|
||||
</span>
|
||||
<span className={checks.special ? "text-tertiary" : "text-outline"}>
|
||||
Special character
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="editorial-gradient w-full py-5 rounded-xl shadow-2xl shadow-primary/20 text-on-primary font-headline font-bold text-lg hover:scale-[1.02] active:scale-95 transition-all duration-200 disabled:opacity-60 disabled:scale-100"
|
||||
disabled={!newPassword || !confirmPassword}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="mt-12 pt-8 border-t border-surface-container text-center">
|
||||
<p className="text-sm text-on-surface-variant font-medium">
|
||||
Remember your password?
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-secondary font-bold hover:text-primary transition-colors ml-1"
|
||||
>
|
||||
Go back to Login
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-8 right-8">
|
||||
<button className="bg-surface-container-high text-on-surface w-14 h-14 rounded-full shadow-lg flex items-center justify-center hover:bg-surface-dim transition-colors group">
|
||||
<span className="material-symbols-outlined text-2xl group-hover:scale-110 transition-transform">
|
||||
help_outline
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
173
src/app/(dashboard)/dashboard/help/page.tsx
Normal file
173
src/app/(dashboard)/dashboard/help/page.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const categories = [
|
||||
{
|
||||
icon: "payments",
|
||||
title: "Payment Protocol",
|
||||
desc: "Financial clearing cycles, escrow rules, and institutional wire frameworks.",
|
||||
},
|
||||
{
|
||||
icon: "local_shipping",
|
||||
title: "Shipping & Logistics",
|
||||
desc: "Global supply chain tracking, customs documentation, and freight insurance.",
|
||||
},
|
||||
{
|
||||
icon: "verified_user",
|
||||
title: "Account & Compliance",
|
||||
desc: "KYC requirements, identity verification, and regional trading licenses.",
|
||||
},
|
||||
{
|
||||
icon: "language",
|
||||
title: "Global Trading Rules",
|
||||
desc: "International trade laws, market restrictions, and tariff updates.",
|
||||
},
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
"How to verify my business identity?",
|
||||
"What are the shipping limits for Europe?",
|
||||
"How to contact a trade curator?",
|
||||
];
|
||||
|
||||
export default function HelpPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
{/* Hero */}
|
||||
<section className="relative bg-surface-container-low py-20 px-8 md:px-16 overflow-hidden">
|
||||
{/* Dot grid */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(#5b403d 0.5px, transparent 0.5px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
{/* Decorative accent */}
|
||||
<div className="absolute top-0 right-0 w-1/3 h-full opacity-10 pointer-events-none">
|
||||
<div className="w-full h-full bg-primary rotate-12 translate-x-24 -translate-y-12" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-4xl">
|
||||
<h1 className="text-5xl md:text-6xl font-black font-headline text-on-surface tracking-tighter leading-none mb-8">
|
||||
How can we help <br />
|
||||
<span className="text-primary">you today?</span>
|
||||
</h1>
|
||||
<div className="relative group max-w-2xl">
|
||||
<span className="material-symbols-outlined absolute left-5 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-primary transition-colors">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full h-14 pl-14 pr-6 bg-white shadow-xl text-base font-medium placeholder:text-slate-400 focus:ring-2 focus:ring-primary/20 focus:outline-none rounded-xl border-none transition-all"
|
||||
placeholder="Search for documentation, protocols, or support articles..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Popular Categories */}
|
||||
<section className="px-8 md:px-16 py-14">
|
||||
<div className="flex items-end justify-between mb-10">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-primary uppercase tracking-[0.3em] mb-1">Resource Infrastructure</p>
|
||||
<h2 className="text-2xl font-black font-headline tracking-tight uppercase">Popular Categories</h2>
|
||||
</div>
|
||||
<div className="h-[2px] flex-1 bg-surface-container ml-8 mb-1.5 hidden md:block" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.title}
|
||||
className="bg-white p-8 rounded-xl shadow-sm hover:-translate-y-1 hover:shadow-xl transition-all cursor-pointer group relative overflow-hidden border border-surface-container"
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity rounded-l-xl" />
|
||||
<span className="material-symbols-outlined text-4xl text-primary mb-5 block">{cat.icon}</span>
|
||||
<h3 className="text-base font-black font-headline mb-2 uppercase tracking-tight">{cat.title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{cat.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ + Contact Support */}
|
||||
<section className="px-8 md:px-16 pb-16 grid grid-cols-1 lg:grid-cols-3 gap-10">
|
||||
{/* FAQ */}
|
||||
<div className="lg:col-span-2">
|
||||
<h2 className="text-xl font-black font-headline tracking-tight uppercase mb-6">
|
||||
Top Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((q) => (
|
||||
<div
|
||||
key={q}
|
||||
className="p-5 bg-surface-container-low rounded-xl flex justify-between items-center group hover:bg-surface-container transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="font-bold text-on-surface">{q}</span>
|
||||
<span className="material-symbols-outlined text-primary group-hover:translate-x-1 transition-transform">
|
||||
arrow_forward
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Support */}
|
||||
<div className="relative bg-primary rounded-2xl p-8 text-white flex flex-col justify-between overflow-hidden shadow-2xl shadow-primary/20 min-h-[320px]">
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-20"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(#fff 0.5px, transparent 0.5px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-2xl font-black font-headline leading-tight uppercase mb-4">
|
||||
Can't find what you're looking for?
|
||||
</h2>
|
||||
<p className="text-white/80 text-sm leading-relaxed mb-6">
|
||||
Our expert trade curators are available 24/7 for institutional grade support and technical troubleshooting.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative z-10 space-y-3">
|
||||
<button className="w-full bg-white text-primary font-black py-3.5 rounded-xl flex items-center justify-center gap-2 hover:bg-surface-container-low transition-colors uppercase text-xs tracking-widest">
|
||||
<span className="material-symbols-outlined text-base">chat_bubble</span>
|
||||
Open a Ticket
|
||||
</button>
|
||||
<button className="w-full border-2 border-white/40 text-white font-black py-3.5 rounded-xl flex items-center justify-center gap-2 hover:bg-white/10 transition-colors uppercase text-xs tracking-widest">
|
||||
<span className="material-symbols-outlined text-base">support_agent</span>
|
||||
Contact Human Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-slate-50 border-t border-surface-container flex flex-col md:flex-row justify-between items-center gap-6 w-full py-10 px-10">
|
||||
<div className="flex flex-col items-center md:items-start gap-2">
|
||||
<div className="font-black text-slate-900 font-headline text-lg">Ina Trading</div>
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest text-slate-400 text-center md:text-left max-w-xs">
|
||||
© 2024 Ina Trading Marketplace. All Rights Reserved.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{["Privacy Policy", "Compliance Framework", "Terms of Service", "Operational Status"].map((l) => (
|
||||
<a
|
||||
key={l}
|
||||
href="#"
|
||||
className="text-[10px] font-medium uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
{l}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
src/app/(dashboard)/dashboard/page.tsx
Normal file
232
src/app/(dashboard)/dashboard/page.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const recentOrders = [
|
||||
{
|
||||
id: "ORD-001",
|
||||
product: "Titan Minimalist V2",
|
||||
sku: "TM-9920",
|
||||
customer: "Sarah Jenkins",
|
||||
location: "London, UK",
|
||||
date: "Oct 24, 2023",
|
||||
amount: "$249.00",
|
||||
status: "Processing",
|
||||
statusColor: "text-primary bg-primary/10",
|
||||
},
|
||||
{
|
||||
id: "ORD-002",
|
||||
product: "Sonic Wave Pro",
|
||||
sku: "SW-1021",
|
||||
customer: "Marcus Thorne",
|
||||
location: "Berlin, DE",
|
||||
date: "Oct 23, 2023",
|
||||
amount: "$499.00",
|
||||
status: "Shipped",
|
||||
statusColor: "text-secondary bg-secondary/10",
|
||||
},
|
||||
{
|
||||
id: "ORD-003",
|
||||
product: "Pulse Runner X",
|
||||
sku: "PR-8821",
|
||||
customer: "Elena Rodriguez",
|
||||
location: "Madrid, ES",
|
||||
date: "Oct 23, 2023",
|
||||
amount: "$125.00",
|
||||
status: "Delivered",
|
||||
statusColor: "text-tertiary bg-tertiary/10",
|
||||
},
|
||||
];
|
||||
|
||||
const barHeights = [40, 65, 50, 85, 70, 60, 95, 45, 55, 80];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useLanguage();
|
||||
const d = t.dashboard.overview;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Hero Title */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-5xl font-black font-headline tracking-tighter text-on-surface mb-2">
|
||||
{d.title}
|
||||
</h1>
|
||||
<p className="text-on-surface-variant font-medium">{d.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-10">
|
||||
{/* Total Products */}
|
||||
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-primary"></div>
|
||||
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
|
||||
{d.totalProducts}
|
||||
</p>
|
||||
<h3 className="text-5xl font-black font-headline text-primary">1,284</h3>
|
||||
<div className="flex items-center gap-2 mt-4 text-secondary font-bold text-sm">
|
||||
<span className="material-symbols-outlined text-sm">trending_up</span>
|
||||
<span>+12.5% {d.vsLastMonth}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Buyers */}
|
||||
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-secondary"></div>
|
||||
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
|
||||
{d.totalBuyers}
|
||||
</p>
|
||||
<h3 className="text-5xl font-black font-headline text-secondary">42,502</h3>
|
||||
<div className="flex items-center gap-2 mt-4 text-tertiary font-bold text-sm">
|
||||
<span className="material-symbols-outlined text-sm">group</span>
|
||||
<span>{d.globalReach}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refunds */}
|
||||
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-tertiary"></div>
|
||||
<p className="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2">
|
||||
{d.refunds}
|
||||
</p>
|
||||
<h3 className="text-5xl font-black font-headline text-tertiary">142</h3>
|
||||
<div className="flex items-center gap-2 mt-4 text-error font-bold text-sm">
|
||||
<span className="material-symbols-outlined text-sm">history</span>
|
||||
<span>0.3% {d.returnRate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-10">
|
||||
{/* Orders Analytics Chart */}
|
||||
<div className="lg:col-span-2 bg-surface-container-lowest p-8 rounded-xl magazine-shadow">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h4 className="text-xl font-black font-headline tracking-tight">{d.ordersAnalytics}</h4>
|
||||
<p className="text-sm text-on-surface-variant font-medium">{d.ordersSubtitle}</p>
|
||||
</div>
|
||||
<select className="bg-surface-container-low border border-surface-container-high rounded-xl text-sm font-bold text-on-surface px-3 py-2 outline-none focus:border-primary">
|
||||
<option>{d.last30Days}</option>
|
||||
<option>{d.lastQuarter}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bar Chart */}
|
||||
<div className="h-64 flex items-end justify-between gap-2 px-2">
|
||||
{barHeights.map((height, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-full rounded-t-lg transition-all ${
|
||||
i === 4 ? "bg-primary" : "bg-surface-container hover:bg-primary/20"
|
||||
}`}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-4 px-2 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">
|
||||
<span>{d.wk} 1</span>
|
||||
<span>{d.wk} 2</span>
|
||||
<span>{d.wk} 3</span>
|
||||
<span>{d.wk} 4</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Earnings */}
|
||||
<div className="bg-surface-container-lowest p-8 rounded-xl magazine-shadow flex flex-col">
|
||||
<h4 className="text-xl font-black font-headline tracking-tight mb-6">{d.earnings}</h4>
|
||||
|
||||
{/* Donut Chart Placeholder */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-44 h-44 rounded-full border-[16px] border-surface-container flex items-center justify-center relative">
|
||||
<div
|
||||
className="absolute inset-[-16px] rounded-full border-[16px] border-primary"
|
||||
style={{ clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 50%)" }}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<span className="block text-3xl font-black font-headline">$84.2k</span>
|
||||
<span className="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">
|
||||
{d.grossRevenue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-4 mt-6">
|
||||
{[
|
||||
{ color: "bg-primary", label: d.directSales, pct: "65%" },
|
||||
{ color: "bg-secondary", label: d.retailPartners, pct: "25%" },
|
||||
{ color: "bg-tertiary", label: d.affiliates, pct: "10%" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-3 h-3 ${item.color} rounded-full`}></span>
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
</div>
|
||||
<span className="font-bold text-on-surface-variant">{item.pct}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Orders Table */}
|
||||
<div className="bg-surface-container-lowest rounded-xl magazine-shadow overflow-hidden">
|
||||
<div className="p-8 flex items-center justify-between border-b border-surface-container">
|
||||
<h4 className="text-xl font-black font-headline tracking-tight">{d.recentOrders}</h4>
|
||||
<button className="text-primary font-bold text-sm hover:underline flex items-center gap-1">
|
||||
{d.viewAll}{" "}
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-surface-container-low text-[10px] font-black text-on-surface-variant uppercase tracking-widest">
|
||||
<th className="px-8 py-4">{d.productDetails}</th>
|
||||
<th className="px-8 py-4">{d.customer}</th>
|
||||
<th className="px-8 py-4">{d.transactionDate}</th>
|
||||
<th className="px-8 py-4">{d.amount}</th>
|
||||
<th className="px-8 py-4">{d.status}</th>
|
||||
<th className="px-8 py-4 text-right">{d.action}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-container">
|
||||
{recentOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-surface-container-low/50 transition-colors">
|
||||
<td className="px-8 py-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-surface-container flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-on-surface-variant">inventory_2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-sm">{order.product}</p>
|
||||
<p className="text-xs text-on-surface-variant">SKU: {order.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-5">
|
||||
<p className="text-sm font-semibold">{order.customer}</p>
|
||||
<p className="text-xs text-on-surface-variant">{order.location}</p>
|
||||
</td>
|
||||
<td className="px-8 py-5 text-sm font-medium text-on-surface-variant">{order.date}</td>
|
||||
<td className="px-8 py-5 text-sm font-bold">{order.amount}</td>
|
||||
<td className="px-8 py-5">
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tighter ${order.statusColor}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-5 text-right">
|
||||
<button className="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors">
|
||||
more_horiz
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
381
src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx
Normal file
381
src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx
Normal file
@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { COUNTRIES } from "@/lib/countries";
|
||||
|
||||
export interface WarehouseFormState {
|
||||
name: string;
|
||||
address: string;
|
||||
country: string;
|
||||
province: string;
|
||||
provinceId: string;
|
||||
city: string;
|
||||
cityId: string;
|
||||
postalCode: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
warehouseType: string;
|
||||
}
|
||||
|
||||
export const defaultWarehouseForm: WarehouseFormState = {
|
||||
name: "",
|
||||
address: "",
|
||||
country: "Indonesia",
|
||||
province: "",
|
||||
provinceId: "",
|
||||
city: "",
|
||||
cityId: "",
|
||||
postalCode: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
warehouseType: "INA",
|
||||
};
|
||||
|
||||
interface Province { id: string; name: string; }
|
||||
interface City { id: string; name: string; }
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function normalizeToken(t: string) {
|
||||
if (!t) return "";
|
||||
return t.startsWith("Bearer ") ? t : `Bearer ${t}`;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-surface-container-low border-b-2 border-outline/30 focus:border-primary border-t-0 border-x-0 rounded-t-lg px-4 py-3 text-sm font-medium text-on-surface placeholder:text-slate-400 focus:ring-0 focus:outline-none transition-all";
|
||||
|
||||
const labelCls = "block text-xs font-black uppercase tracking-[0.15em] text-slate-500 mb-2";
|
||||
|
||||
interface WarehouseFormProps {
|
||||
initialData?: WarehouseFormState;
|
||||
pageTitle: string;
|
||||
pageSubtitle: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
successMessage: string;
|
||||
apiMethod: "POST" | "PUT";
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export function WarehouseForm({
|
||||
initialData,
|
||||
pageTitle,
|
||||
pageSubtitle,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
successMessage,
|
||||
apiMethod,
|
||||
apiUrl,
|
||||
}: WarehouseFormProps) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<WarehouseFormState>(initialData ?? defaultWarehouseForm);
|
||||
const [provinces, setProvinces] = useState<Province[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [loadingProvinces, setLoadingProvinces] = useState(false);
|
||||
const [loadingCities, setLoadingCities] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingPhase, setSavingPhase] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const isIndonesia = form.country === "Indonesia";
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) setForm(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndonesia) {
|
||||
setProvinces([]);
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setLoadingProvinces(true);
|
||||
fetch("/api/locations/provinces", { headers: { "x-auth-token": normalizeToken(getToken()) } })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProvinces(Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => setProvinces([]))
|
||||
.finally(() => setLoadingProvinces(false));
|
||||
}, [isIndonesia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndonesia || !form.provinceId) {
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setLoadingCities(true);
|
||||
fetch(`/api/locations/cities?provinceId=${form.provinceId}`, {
|
||||
headers: { "x-auth-token": normalizeToken(getToken()) },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCities(Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => setCities([]))
|
||||
.finally(() => setLoadingCities(false));
|
||||
}, [isIndonesia, form.provinceId]);
|
||||
|
||||
function update(patch: Partial<WarehouseFormState>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.address.trim()) { setError("Alamat wajib diisi"); return; }
|
||||
|
||||
setSaving(true);
|
||||
setSavingPhase("Menyimpan perubahan...");
|
||||
setError("");
|
||||
|
||||
const payload: Record<string, string | number | null> = {
|
||||
name: form.name || null,
|
||||
address: form.address,
|
||||
country: form.country || null,
|
||||
province: form.province || null,
|
||||
city: form.city || null,
|
||||
postalCode: form.postalCode || null,
|
||||
latitude: form.latitude ? parseFloat(form.latitude) : null,
|
||||
longitude: form.longitude ? parseFloat(form.longitude) : null,
|
||||
warehouseType: form.warehouseType || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const token = normalizeToken(getToken());
|
||||
const res = await fetch(apiUrl, {
|
||||
method: apiMethod,
|
||||
headers: { "Content-Type": "application/json", "x-auth-token": token },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data?.responseDesc || data?.error || "Gagal menyimpan warehouse");
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/dashboard/warehouse"), 1500);
|
||||
} catch {
|
||||
setError("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingPhase("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-screen">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<span className="text-primary font-bold tracking-widest text-xs uppercase">Management Dashboard</span>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-on-surface mt-1">{pageTitle}</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">{pageSubtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-white rounded-2xl border-l-4 border-primary shadow-sm p-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-8">Facility Details</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Name */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Nama Warehouse</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
placeholder="Contoh: Gudang Utama Jakarta..."
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Alamat *</label>
|
||||
<input
|
||||
value={form.address}
|
||||
onChange={(e) => update({ address: e.target.value })}
|
||||
placeholder="Jl. Nama Jalan No. ..."
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Negara</label>
|
||||
<select
|
||||
value={form.country}
|
||||
onChange={(e) =>
|
||||
update({ country: e.target.value, province: "", provinceId: "", city: "", cityId: "" })
|
||||
}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">Pilih negara...</option>
|
||||
{COUNTRIES.map((c) => (
|
||||
<option key={c.code} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Province */}
|
||||
<div>
|
||||
<label className={labelCls}>Provinsi / State</label>
|
||||
{isIndonesia ? (
|
||||
<select
|
||||
value={form.provinceId}
|
||||
onChange={(e) => {
|
||||
const selected = provinces.find((p) => p.id === e.target.value);
|
||||
update({ provinceId: e.target.value, province: selected?.name || "", city: "", cityId: "" });
|
||||
}}
|
||||
disabled={loadingProvinces}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">
|
||||
{loadingProvinces ? "Memuat provinsi..." : "Pilih provinsi..."}
|
||||
</option>
|
||||
{provinces.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.province}
|
||||
onChange={(e) => update({ province: e.target.value })}
|
||||
placeholder="Nama provinsi / state..."
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div>
|
||||
<label className={labelCls}>Kota</label>
|
||||
{isIndonesia ? (
|
||||
<select
|
||||
value={form.cityId}
|
||||
onChange={(e) => {
|
||||
const selected = cities.find((c) => c.id === e.target.value);
|
||||
update({ cityId: e.target.value, city: selected?.name || "" });
|
||||
}}
|
||||
disabled={!form.provinceId || loadingCities}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">
|
||||
{!form.provinceId ? "Pilih provinsi dulu..." : loadingCities ? "Memuat kota..." : "Pilih kota..."}
|
||||
</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.city}
|
||||
onChange={(e) => update({ city: e.target.value })}
|
||||
placeholder="Nama kota..."
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Postal Code */}
|
||||
<div>
|
||||
<label className={labelCls}>Kode Pos</label>
|
||||
<input
|
||||
value={form.postalCode}
|
||||
onChange={(e) => update({ postalCode: e.target.value })}
|
||||
placeholder="Contoh: 13920"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warehouse Type */}
|
||||
<div>
|
||||
<label className={labelCls}>Tipe Warehouse</label>
|
||||
<select
|
||||
value={form.warehouseType}
|
||||
onChange={(e) => update({ warehouseType: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="INA">INA</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Lat / Lng */}
|
||||
<div>
|
||||
<label className={labelCls}>Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.latitude}
|
||||
onChange={(e) => update({ latitude: e.target.value })}
|
||||
placeholder="Contoh: -6.1891"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.longitude}
|
||||
onChange={(e) => update({ longitude: e.target.value })}
|
||||
placeholder="Contoh: 106.9247"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status messages + buttons */}
|
||||
<div className="mt-10 space-y-4">
|
||||
{success && (
|
||||
<div className="p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{savingPhase && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
{savingPhase}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm uppercase tracking-[0.15em] hover:bg-surface-container-low transition-colors disabled:opacity-40"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || success}
|
||||
className="bg-gradient-to-br from-primary to-primary-container text-white px-10 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-xl shadow-primary/20 hover:scale-105 active:scale-95 transition-all disabled:opacity-60 disabled:hover:scale-100"
|
||||
>
|
||||
{saving ? (savingPhase || submittingLabel) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { WarehouseForm, WarehouseFormState } from "../../WarehouseForm";
|
||||
|
||||
function getCachedWarehouse(warehouseId: string): WarehouseFormState | null {
|
||||
if (typeof window === "undefined" || !warehouseId) return null;
|
||||
const cached = sessionStorage.getItem("editWarehouseCache");
|
||||
if (!cached) return null;
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(cached) as Record<string, unknown>;
|
||||
if (raw.id !== warehouseId) return null;
|
||||
sessionStorage.removeItem("editWarehouseCache");
|
||||
|
||||
return {
|
||||
name: typeof raw.name === "string" ? raw.name : "",
|
||||
address: typeof raw.address === "string" ? raw.address : "",
|
||||
country: typeof raw.country === "string" ? raw.country : "Indonesia",
|
||||
province: typeof raw.province === "string" ? raw.province : "",
|
||||
provinceId: "",
|
||||
city: typeof raw.city === "string" ? raw.city : "",
|
||||
cityId: "",
|
||||
postalCode: typeof raw.postalCode === "string" ? raw.postalCode : "",
|
||||
latitude: raw.latitude != null ? String(raw.latitude) : "",
|
||||
longitude: raw.longitude != null ? String(raw.longitude) : "",
|
||||
warehouseType:
|
||||
typeof raw.warehouseType === "string" ? raw.warehouseType : "INA",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditWarehousePage() {
|
||||
const params = useParams<{ warehouseId: string }>();
|
||||
const router = useRouter();
|
||||
const [initialData] = useState<WarehouseFormState | null>(() =>
|
||||
getCachedWarehouse(params.warehouseId)
|
||||
);
|
||||
|
||||
const loadError = initialData
|
||||
? ""
|
||||
: "Data tidak tersedia. Kembali ke daftar warehouse dan klik Edit lagi.";
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="p-8 flex flex-col items-center justify-center h-64 gap-4">
|
||||
<span className="material-symbols-outlined text-4xl text-error">error</span>
|
||||
<p className="text-sm font-semibold text-error">{loadError}</p>
|
||||
<button onClick={() => router.back()} className="text-sm font-bold text-primary hover:underline">Kembali</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!initialData) {
|
||||
return (
|
||||
<div className="p-8 flex items-center justify-center h-64">
|
||||
<span className="material-symbols-outlined text-4xl text-slate-300 animate-spin">progress_activity</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WarehouseForm
|
||||
initialData={initialData}
|
||||
pageTitle="Edit Warehouse"
|
||||
pageSubtitle="Perbarui informasi gudang"
|
||||
submitLabel="Simpan Perubahan"
|
||||
submittingLabel="Menyimpan..."
|
||||
successMessage="Warehouse berhasil diperbarui! Mengalihkan..."
|
||||
apiMethod="PUT"
|
||||
apiUrl={`/api/warehouses/${params.warehouseId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/app/(dashboard)/dashboard/warehouse/new/page.tsx
Normal file
17
src/app/(dashboard)/dashboard/warehouse/new/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { WarehouseForm } from "../WarehouseForm";
|
||||
|
||||
export default function NewWarehousePage() {
|
||||
return (
|
||||
<WarehouseForm
|
||||
pageTitle="Tambah Warehouse"
|
||||
pageSubtitle="Daftarkan gudang baru ke dalam sistem"
|
||||
submitLabel="Simpan Warehouse"
|
||||
submittingLabel="Menyimpan..."
|
||||
successMessage="Warehouse berhasil ditambahkan! Mengalihkan..."
|
||||
apiMethod="POST"
|
||||
apiUrl="/api/warehouses"
|
||||
/>
|
||||
);
|
||||
}
|
||||
311
src/app/(dashboard)/dashboard/warehouse/page.tsx
Normal file
311
src/app/(dashboard)/dashboard/warehouse/page.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface WarehouseRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
country: string | null;
|
||||
postalCode: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
warehouseType: string | null;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function normalizeToken(t: string) {
|
||||
if (!t) return "";
|
||||
return t.startsWith("Bearer ") ? t : `Bearer ${t}`;
|
||||
}
|
||||
|
||||
function typeBadge(type: string | null) {
|
||||
if (type === "INA") return "bg-primary-fixed text-on-primary-fixed-variant";
|
||||
if (type === "Other") return "bg-secondary-fixed text-on-secondary-fixed-variant";
|
||||
return "bg-slate-100 text-slate-500";
|
||||
}
|
||||
|
||||
export default function WarehousePage() {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState<WarehouseRow[]>([]);
|
||||
const [filtered, setFiltered] = useState<WarehouseRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalItem, setTotalItem] = useState(0);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const token = normalizeToken(getToken());
|
||||
const res = await fetch(`/api/products/warehouses?page=${page + 1}&size=${pageSize}`, {
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
const data = await res.json();
|
||||
const list: WarehouseRow[] = Array.isArray(data?.rows) ? data.rows : [];
|
||||
setRows(list);
|
||||
setFiltered(list);
|
||||
setTotalItem(data?.totalItem ?? 0);
|
||||
setTotalPage(data?.totalPage ?? 1);
|
||||
} catch {
|
||||
setError("Gagal memuat data warehouse");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase();
|
||||
setFiltered(
|
||||
rows.filter(
|
||||
(r) =>
|
||||
(r.name || "").toLowerCase().includes(q) ||
|
||||
(r.address || "").toLowerCase().includes(q) ||
|
||||
(r.city || "").toLowerCase().includes(q) ||
|
||||
(r.province || "").toLowerCase().includes(q) ||
|
||||
(r.country || "").toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
}, [search, rows]);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const token = normalizeToken(getToken());
|
||||
const res = await fetch(`/api/warehouses/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
alert(d?.responseDesc || "Gagal menghapus warehouse");
|
||||
return;
|
||||
}
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch {
|
||||
alert("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const startEntry = page * pageSize + 1;
|
||||
const endEntry = Math.min((page + 1) * pageSize, totalItem);
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-screen">
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-error-container flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-error">delete</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-on-surface">Hapus Warehouse?</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Tindakan ini tidak bisa dibatalkan. Warehouse akan dihapus secara permanen.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteId(null)}
|
||||
disabled={deleting}
|
||||
className="px-5 py-2.5 rounded-xl border border-outline-variant/30 text-sm font-black text-on-surface hover:bg-surface-container-low transition-colors disabled:opacity-40"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteId)}
|
||||
disabled={deleting}
|
||||
className="px-5 py-2.5 rounded-xl bg-error text-white text-sm font-black hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||
>
|
||||
{deleting ? "Menghapus..." : "Hapus"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<span className="text-primary font-bold tracking-widest text-xs uppercase">Management Dashboard</span>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-on-surface mt-1">Warehouse</h1>
|
||||
<p className="text-slate-500 font-semibold mt-1">Kelola gudang dan lokasi penyimpanan produk</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-white border border-zinc-100 h-12 px-4 flex items-center rounded-xl shadow-sm">
|
||||
<span className="material-symbols-outlined text-slate-400 mr-2 text-lg">search</span>
|
||||
<input
|
||||
className="bg-transparent border-none focus:ring-0 text-sm font-medium w-48 lg:w-64 placeholder:text-slate-300 outline-none"
|
||||
placeholder="Cari berdasarkan lokasi..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/warehouse/new"
|
||||
className="h-12 px-6 flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white rounded-xl font-bold text-sm hover:opacity-90 transition-opacity shadow-lg shadow-primary/20"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">add</span>
|
||||
<span>Tambah Warehouse</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-2xl overflow-hidden border border-zinc-100 shadow-sm">
|
||||
{loading ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block animate-spin">progress_activity</span>
|
||||
<p className="text-sm font-medium">Memuat data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-16 text-center text-error">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">error</span>
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">warehouse</span>
|
||||
<p className="text-sm font-medium">Belum ada warehouse</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/50 border-b border-zinc-100">
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Warehouse</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Alamat</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Kode Pos</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400">Tipe</th>
|
||||
<th className="px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-400 text-right">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{filtered.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-50/40 transition-colors group">
|
||||
{/* Warehouse name */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-primary">warehouse</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-on-surface tracking-tight">
|
||||
{item.name || <span className="text-slate-400 font-medium italic">Tanpa nama</span>}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">
|
||||
{item.city && item.province ? `${item.city}, ${item.province}` : item.city || item.province || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Address */}
|
||||
<td className="px-8 py-6">
|
||||
<p className="text-sm font-semibold text-on-surface">{item.address || "—"}</p>
|
||||
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">{item.country || ""}</p>
|
||||
</td>
|
||||
|
||||
{/* Postal code */}
|
||||
<td className="px-8 py-6">
|
||||
<span className="text-sm font-semibold text-on-surface">{item.postalCode || "—"}</span>
|
||||
</td>
|
||||
|
||||
{/* Type */}
|
||||
<td className="px-8 py-6">
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${typeBadge(item.warehouseType)}`}>
|
||||
{item.warehouseType || "—"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-8 py-6 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("editWarehouseCache", JSON.stringify(item));
|
||||
router.push(`/dashboard/warehouse/${item.id}/edit`);
|
||||
}}
|
||||
className="w-10 h-10 flex items-center justify-center text-slate-400 hover:text-on-surface hover:bg-zinc-100 rounded-xl transition-all"
|
||||
title="Edit"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
className="w-10 h-10 flex items-center justify-center text-red-300 hover:text-primary hover:bg-red-50 rounded-xl transition-all"
|
||||
title="Hapus"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && totalItem > 0 && (
|
||||
<div className="px-8 py-5 bg-zinc-50/50 flex items-center justify-between border-t border-zinc-50">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Menampilkan {startEntry}–{endEntry} dari {totalItem} warehouse
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="w-10 h-10 flex items-center justify-center border border-zinc-200 rounded-xl text-slate-400 hover:bg-white transition-all disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPage(i)}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-xl text-xs font-black transition-all ${
|
||||
i === page ? "bg-primary text-white shadow-lg shadow-primary/20" : "border border-zinc-200 text-on-surface hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPage - 1, p + 1))}
|
||||
disabled={page >= totalPage - 1}
|
||||
className="w-10 h-10 flex items-center justify-center border border-zinc-200 rounded-xl text-slate-400 hover:bg-white transition-all disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/app/(dashboard)/layout.tsx
Normal file
155
src/app/(dashboard)/layout.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AppIcon } from "@/components/app-icon";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
import { ProductSubmenuNav } from "@/components/product-submenu-nav";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", icon: "dashboard", label: "Dashboard" },
|
||||
{ href: "/products", icon: "inventory_2", label: "Product" },
|
||||
{ href: "/dashboard/inventory", icon: "inventory", label: "Inventory" },
|
||||
{ href: "/dashboard/warehouse", icon: "warehouse", label: "Warehouse" },
|
||||
{ href: "/dashboard/orders", icon: "shopping_cart", label: "Orders" },
|
||||
{ href: "/dashboard/invoice", icon: "receipt", label: "Invoice" },
|
||||
{ href: "/dashboard/customers", icon: "groups", label: "Customers" },
|
||||
{ href: "/dashboard/marketing", icon: "campaign", label: "Marketing" },
|
||||
{ href: "/dashboard/finance", icon: "payments", label: "Finance" },
|
||||
{ href: "/dashboard/reports", icon: "assessment", label: "Reports" },
|
||||
{ href: "/dashboard/analytics", icon: "analytics", label: "Analytics" },
|
||||
{ href: "/dashboard/shop", icon: "storefront", label: "Shop" },
|
||||
{ href: "/dashboard/reviews", icon: "reviews", label: "Reviews" },
|
||||
{ href: "/settings", icon: "storefront", label: "Settings" },
|
||||
];
|
||||
|
||||
const visibleNavLabels = new Set([
|
||||
"Dashboard",
|
||||
"Product",
|
||||
"Warehouse",
|
||||
// "Orders", // hidden temporarily
|
||||
// "Invoice", // hidden temporarily
|
||||
"Settings",
|
||||
]);
|
||||
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("role");
|
||||
sessionStorage.removeItem("token");
|
||||
sessionStorage.removeItem("role");
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
{/* Top Nav */}
|
||||
<header className="fixed top-0 w-full z-50 flex justify-between items-center px-6 h-16 bg-white/85 backdrop-blur-md shadow-sm border-b border-surface-container">
|
||||
<div className="flex items-center gap-8">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={120} height={32} priority />
|
||||
<div className="hidden md:flex items-center bg-surface-container-low px-4 py-2 rounded-xl gap-2">
|
||||
<AppIcon name="search" className="h-5 w-5 text-on-surface-variant" />
|
||||
<input
|
||||
className="bg-transparent border-none outline-none text-sm w-64 text-on-surface placeholder:text-on-surface-variant"
|
||||
placeholder={t.dashboard.layout.searchPlaceholder}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<button className="text-on-surface-variant hover:text-primary transition-colors p-2 rounded-full relative">
|
||||
<AppIcon name="notifications" className="h-5 w-5" />
|
||||
<span className="absolute top-2.5 right-2.5 w-2 h-2 bg-primary rounded-full"></span>
|
||||
</button>
|
||||
<button className="text-on-surface-variant hover:text-primary transition-colors p-2 rounded-full">
|
||||
<AppIcon name="chat" className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 h-full w-72 bg-surface-container-lowest flex flex-col py-6 border-r border-surface-container z-40">
|
||||
{/* Sidebar Header */}
|
||||
<div className="mt-12 px-6 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-on-primary">
|
||||
<AppIcon name="analytics" className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-black text-primary font-headline leading-none uppercase">{t.dashboard.layout.editorial}</h2>
|
||||
<p className="text-[10px] text-on-surface-variant font-bold tracking-widest uppercase mt-0.5">{t.dashboard.layout.globalExportHub}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav Items */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/products"
|
||||
? pathname === "/products" || pathname.startsWith("/products/")
|
||||
: item.href === "/dashboard/warehouse"
|
||||
? pathname === "/dashboard/warehouse" || pathname.startsWith("/dashboard/warehouse/")
|
||||
: pathname === item.href;
|
||||
|
||||
const isVisible = visibleNavLabels.has(item.label);
|
||||
const label = t.dashboard.layout.nav[item.label as keyof typeof t.dashboard.layout.nav] ?? item.label;
|
||||
const isProduct = item.label === "Product";
|
||||
return (
|
||||
<div key={item.href} className={isVisible ? "block" : "hidden"}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center gap-4 px-4 py-3 text-sm font-semibold font-headline rounded-xl transition-all ${
|
||||
isActive
|
||||
? "bg-white text-primary border-l-4 border-primary shadow-sm ml-0 rounded-l-none"
|
||||
: "text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform"
|
||||
}`}
|
||||
>
|
||||
<AppIcon name={item.icon as Parameters<typeof AppIcon>[0]["name"]} className="h-5 w-5" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
|
||||
{isProduct && isActive ? <ProductSubmenuNav /> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-4 mt-4 border-t border-surface-container">
|
||||
<Link
|
||||
href="/dashboard/help"
|
||||
className="flex items-center gap-4 px-4 py-3 text-sm font-semibold font-headline text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform rounded-xl"
|
||||
>
|
||||
<AppIcon name="help" className="h-5 w-5" />
|
||||
<span>{t.dashboard.layout.helpCenter}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-4 px-4 py-3 text-sm font-semibold font-headline text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform rounded-xl"
|
||||
>
|
||||
<AppIcon name="logout" className="h-5 w-5" />
|
||||
<span>{t.common.logout}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="ml-72 pt-16 min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
516
src/app/(dashboard)/products/[productId]/detail/page.tsx
Normal file
516
src/app/(dashboard)/products/[productId]/detail/page.tsx
Normal file
@ -0,0 +1,516 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
interface ProductWarehouse {
|
||||
id?: string;
|
||||
stock?: number;
|
||||
}
|
||||
|
||||
interface ProductMeasurement {
|
||||
measurementType?: string;
|
||||
measurementValue?: string;
|
||||
price?: string | number;
|
||||
currency?: string;
|
||||
weight?: string | number;
|
||||
weightType?: string;
|
||||
length?: string | number;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
dimensionType?: string;
|
||||
isConfigurePromotionPrice?: boolean;
|
||||
promotionPrice?: string | number;
|
||||
promotionCurrency?: string;
|
||||
warehouses?: ProductWarehouse[];
|
||||
}
|
||||
|
||||
interface ProductModel {
|
||||
warehouses?: ProductWarehouse[];
|
||||
productMeasurements?: ProductMeasurement[];
|
||||
}
|
||||
|
||||
interface ProductImage {
|
||||
sequence?: number;
|
||||
imageId?: string;
|
||||
}
|
||||
|
||||
interface ProductInfoItem {
|
||||
paramName: string;
|
||||
paramValue: string;
|
||||
}
|
||||
|
||||
interface ProductCategory {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ProductSubCategory {
|
||||
id?: string;
|
||||
name?: string;
|
||||
category?: ProductCategory;
|
||||
}
|
||||
|
||||
interface ProductDetail {
|
||||
name?: string;
|
||||
state?: string;
|
||||
subCategory?: ProductSubCategory;
|
||||
isPreOrder?: boolean;
|
||||
isNew?: boolean;
|
||||
isEligibleToExport?: boolean;
|
||||
preOrderDay?: string | number;
|
||||
description?: string;
|
||||
imageId?: string;
|
||||
productImages?: ProductImage[];
|
||||
productModels?: ProductModel[];
|
||||
productKeyWords?: string[];
|
||||
productFeatures?: string[];
|
||||
productInformations?: ProductInfoItem[];
|
||||
categoryInformations?: ProductInfoItem[];
|
||||
complianceInformation?: {
|
||||
countryOfOrigin?: string;
|
||||
safetyWarning?: string;
|
||||
isDangerousGoodRegulation?: boolean;
|
||||
};
|
||||
warrantyInformation?: {
|
||||
type?: string;
|
||||
duration?: string | number;
|
||||
durationType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function SectionHeader({ step, title }: { step: string; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center text-white text-xs font-black shadow-md shadow-primary/20">
|
||||
{step}
|
||||
</div>
|
||||
<h2 className="text-xl font-black font-headline tracking-tight text-on-surface">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
||||
if (value === "" || value === undefined || value === null) return null;
|
||||
const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value);
|
||||
return (
|
||||
<div className="flex justify-between gap-4 py-2 border-b border-surface-container last:border-0 text-sm">
|
||||
<span className="text-on-surface-variant font-medium flex-shrink-0">{label}</span>
|
||||
<span className="font-semibold text-on-surface text-right">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function ToggleBadge({ label, value }: { label: string; value: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-surface-container-low">
|
||||
<span className="text-sm font-bold text-on-surface">{label}</span>
|
||||
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${value ? "bg-primary/10 text-primary" : "bg-surface-container text-outline"}`}>
|
||||
{value ? "Ya" : "Tidak"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductDetailPageInner() {
|
||||
const { t } = useLanguage();
|
||||
const d = t.dashboard.productDetail;
|
||||
const params = useParams<{ productId: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const isDraft = searchParams.get("draft") === "1";
|
||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const errorLoadText = d.errorLoad;
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.productId) return;
|
||||
const url = `/api/products/${params.productId}${isDraft ? "?draft=1" : ""}`;
|
||||
fetch(url, { headers: { "x-auth-token": getToken() } })
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (!j) throw new Error("No data");
|
||||
setProduct(j?.data || j);
|
||||
})
|
||||
.catch(() => setError(errorLoadText))
|
||||
.finally(() => setLoading(false));
|
||||
}, [errorLoadText, params.productId, isDraft]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full px-6 md:px-10 py-8 flex items-center justify-center py-32">
|
||||
<p className="text-sm font-semibold text-on-surface-variant">{d.loading}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="w-full px-6 md:px-10 py-8">
|
||||
<div className="rounded-xl border border-error/20 bg-error-container p-6 text-sm font-semibold text-on-error-container">
|
||||
{error || d.notFound}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const models: ProductModel[] = Array.isArray(product.productModels)
|
||||
? product.productModels
|
||||
: [];
|
||||
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords.filter(Boolean) : [];
|
||||
const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
|
||||
const productInfos = Array.isArray(product.productInformations) ? product.productInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
|
||||
const categoryInfos = Array.isArray(product.categoryInformations) ? product.categoryInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
|
||||
const allImages: string[] = [
|
||||
...(product.imageId ? [product.imageId] : []),
|
||||
...(Array.isArray(product.productImages)
|
||||
? product.productImages
|
||||
.sort(
|
||||
(a: ProductImage, b: ProductImage) =>
|
||||
(a.sequence ?? 0) - (b.sequence ?? 0)
|
||||
)
|
||||
.map((img: ProductImage) => img.imageId)
|
||||
.filter(isNonEmptyString)
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 md:px-10 py-8 pb-36 space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-2">
|
||||
<nav className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-outline mb-4">
|
||||
<span>{d.breadcrumbProducts}</span>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
<span>{d.breadcrumbEditor}</span>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
<span className="text-primary">{d.title}</span>
|
||||
</nav>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black font-headline tracking-tighter text-on-surface">{product.name || d.title}</h1>
|
||||
<p className="mt-2 text-on-surface-variant font-medium">{product.state || "DRAFT"}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`}
|
||||
className="editorial-gradient text-white px-6 py-3 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-md shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
{d.editProduct}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 01: Basic Details (Category) ───────────────────────────── */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="01" title={d.section01} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-surface-container-low">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.mainCategory}</p>
|
||||
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || "—"}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-surface-container-low">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.subCategory}</p>
|
||||
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.name || product.subCategory?.id || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 02: Description ────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||||
{/* Left */}
|
||||
<div className="xl:col-span-7 bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8 space-y-6">
|
||||
<SectionHeader step="02" title={d.section02} />
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.officialName}</p>
|
||||
<p className="text-base font-semibold text-on-surface">{product.name || "—"}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ToggleBadge label={d.preOrder} value={!!product.isPreOrder} />
|
||||
<ToggleBadge label={d.brandNew} value={product.isNew !== false} />
|
||||
</div>
|
||||
|
||||
{product.isPreOrder && (
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.preOrderDay}</p>
|
||||
<p className="text-sm font-semibold text-on-surface">{product.preOrderDay || "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{keywords.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.keywords}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywords.map((k: string) => (
|
||||
<span key={k} className="rounded-full bg-primary/5 px-3 py-2 text-xs font-black text-primary border border-primary/10">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.narrative}</p>
|
||||
<p className="text-sm font-medium text-on-surface leading-relaxed whitespace-pre-line">{product.description || "—"}</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
{features.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.features}</p>
|
||||
<div className="space-y-2">
|
||||
{features.map((f: string, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low">
|
||||
<span className="material-symbols-outlined text-outline/30 text-lg">drag_indicator</span>
|
||||
<p className="flex-1 text-sm font-semibold text-on-surface">{f}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Visual Identity */}
|
||||
<div className="xl:col-span-5 bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8 space-y-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.visualIdentity}</p>
|
||||
{allImages.length === 0 ? (
|
||||
<div className="aspect-square rounded-2xl bg-surface-container-low flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-5xl text-outline/30">image</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{allImages.map((imgId, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
|
||||
<div className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${API_BASE}/api/v1.0/file/image/${imgId}`}
|
||||
alt={i === 0 ? "Main Image" : `Gallery ${i}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{i === 0 ? d.mainImage : `${d.gallery} ${i}`}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-on-surface truncate mt-0.5">{t.common.uploaded}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 p-4 rounded-2xl bg-primary/5 border border-primary/10 flex gap-3">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] shrink-0">info</span>
|
||||
<p className="text-[11px] font-bold leading-relaxed text-on-surface-variant">
|
||||
{allImages.length} {d.imagesAvailable}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 03: Pricing & Model ───────────────────────────────────── */}
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center text-white text-xs font-black shadow-md shadow-primary/20">03</div>
|
||||
<h2 className="text-xl font-black font-headline tracking-tight text-on-surface">{d.section03} ({models.length})</h2>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{models.map((m: any, i: number) => {
|
||||
const weightUnit = m.weightType || "G";
|
||||
const dimUnit = m.dimensionType || "CM";
|
||||
const pkgWeightUnit = m.packagingWeightType || "G";
|
||||
const pkgDimUnit = m.packagingDimensionType || "CM";
|
||||
const measurements = Array.isArray(m.productMeasurements) ? m.productMeasurements : [];
|
||||
return (
|
||||
<div key={i} className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-surface-container">
|
||||
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center text-white text-xs font-black">{i + 1}</div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-on-surface">{m.name || `Model ${i + 1}`}</h3>
|
||||
{m.sku && <span className="text-[10px] text-outline font-bold ml-2">SKU: {m.sku}</span>}
|
||||
{measurements.length > 0 && (
|
||||
<span className="ml-auto text-[10px] font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">{measurements.length} measurement(s)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<Row label={d.price} value={m.price ? `${m.currency || "IDR"} ${Number(m.price).toLocaleString("id-ID")}` : undefined} />
|
||||
<Row label={`${d.price.includes("H") ? "Berat" : "Weight"} (${weightUnit})`} value={m.weight} />
|
||||
<Row label={`Dim (${dimUnit})`} value={[m.length, m.width, m.height].filter(Boolean).join(" × ") || undefined} />
|
||||
{m.isConfigurePromotionPrice && <Row label="Promo" value={m.promotionPrice ? `${m.promotionCurrency || m.currency || "IDR"} ${Number(m.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
|
||||
{m.isConfigurePromotionPrice && m.promotionStartDate && (
|
||||
<Row label="Promo Period" value={`${m.promotionStartDate} → ${m.promotionEndDate}`} />
|
||||
)}
|
||||
<Row label={`Pkg Weight (${pkgWeightUnit})`} value={m.packagingWeight} />
|
||||
<Row label={`Pkg Dim (${pkgDimUnit})`} value={[m.packagingLength, m.packagingWidth, m.packagingHeight].filter(Boolean).join(" × ") || undefined} />
|
||||
</div>
|
||||
{/* Warehouses */}
|
||||
{Array.isArray(m.warehouses) && m.warehouses.length > 0 && (
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-2">{d.warehouseStock}</p>
|
||||
<div className="space-y-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-1 border-b border-surface-container last:border-0">
|
||||
<span className="font-mono">{w.id?.slice(0, 8)}...</span>
|
||||
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Measurements */}
|
||||
{measurements.length > 0 && (
|
||||
<div className="px-6 pb-6 border-t border-surface-container mt-2">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mt-4 mb-3">Measurements / Variants</p>
|
||||
<div className="space-y-3">
|
||||
{measurements.map((ms: ProductMeasurement, mi: number) => {
|
||||
const msWeightUnit = ms.weightType || "G";
|
||||
const msDimUnit = ms.dimensionType || "CM";
|
||||
return (
|
||||
<div key={mi} className="bg-surface-container-low rounded-xl p-4 border border-outline-variant/10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-outline bg-surface-container px-2.5 py-1 rounded-full">
|
||||
{String(mi + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{ms.measurementType && <span className="text-xs font-bold text-on-surface">{ms.measurementType}</span>}
|
||||
{ms.measurementValue && <span className="text-xs text-on-surface-variant">— {ms.measurementValue}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<Row label="Harga" value={ms.price ? `${ms.currency || "IDR"} ${Number(ms.price).toLocaleString("id-ID")}` : undefined} />
|
||||
<Row label={`Berat (${msWeightUnit})`} value={ms.weight} />
|
||||
<Row label={`Dimensi (${msDimUnit})`} value={[ms.length, ms.width, ms.height].filter(Boolean).join(" × ") || undefined} />
|
||||
{ms.isConfigurePromotionPrice && <Row label="Harga Promo" value={ms.promotionPrice ? `${ms.promotionCurrency || ms.currency || "IDR"} ${Number(ms.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
|
||||
</div>
|
||||
{Array.isArray(ms.warehouses) &&
|
||||
ms.warehouses.filter((w: ProductWarehouse) => w.id).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-outline-variant/10">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-outline mb-1.5">Stock</p>
|
||||
{ms.warehouses
|
||||
.filter((w: ProductWarehouse) => w.id)
|
||||
.map((w: ProductWarehouse, wi: number) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
|
||||
<span className="font-mono">{w.id?.slice(0, 8)}...</span>
|
||||
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Section 04: General Info ──────────────────────────────────────── */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8 space-y-8">
|
||||
<SectionHeader step="04" title={d.section04} />
|
||||
|
||||
{productInfos.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-black text-on-surface mb-3">{d.productInfo}</p>
|
||||
{productInfos.map((item: { paramName: string; paramValue: string }, i: number) => (
|
||||
<Row key={i} label={item.paramName} value={item.paramValue} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoryInfos.length > 0 && (
|
||||
<>
|
||||
{productInfos.length > 0 && <div className="border-t border-surface-container" />}
|
||||
<div>
|
||||
<p className="text-sm font-black text-on-surface mb-3">{d.categoryInfo}</p>
|
||||
{categoryInfos.map((item: { paramName: string; paramValue: string }, i: number) => (
|
||||
<Row key={i} label={item.paramName} value={item.paramValue} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{product.complianceInformation && (
|
||||
<>
|
||||
<div className="border-t border-surface-container" />
|
||||
<div>
|
||||
<p className="text-sm font-black text-on-surface mb-3">{d.compliance}</p>
|
||||
<Row label={d.countryOfOrigin} value={product.complianceInformation.countryOfOrigin} />
|
||||
<Row label={d.safetyWarning} value={product.complianceInformation.safetyWarning} />
|
||||
<Row label={d.dangerousGoods} value={product.complianceInformation.isDangerousGoodRegulation} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{product.warrantyInformation && (
|
||||
<>
|
||||
<div className="border-t border-surface-container" />
|
||||
<div>
|
||||
<p className="text-sm font-black text-on-surface mb-3">{d.warranty}</p>
|
||||
<Row label="Type" value={product.warrantyInformation.type} />
|
||||
<Row label={d.warrantyDuration} value={product.warrantyInformation.duration ? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType}` : undefined} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t border-surface-container" />
|
||||
<div>
|
||||
<p className="text-sm font-black text-on-surface mb-3">{d.export}</p>
|
||||
<ToggleBadge label={d.eligibleToExport} value={!!product.isEligibleToExport} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Fixed Bottom Footer ───────────────────────────────────────────── */}
|
||||
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">
|
||||
{d.modeReadOnly}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/products"
|
||||
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
|
||||
Kembali
|
||||
</Link>
|
||||
<Link
|
||||
href={`/products/${params.productId}/edit${isDraft ? "?draft=1" : ""}`}
|
||||
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
Edit Produk
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ProductDetailPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
1744
src/app/(dashboard)/products/[productId]/edit/page.tsx
Normal file
1744
src/app/(dashboard)/products/[productId]/edit/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
224
src/app/(dashboard)/products/new/category/page.tsx
Normal file
224
src/app/(dashboard)/products/new/category/page.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProductDraft } from "@/lib/product-draft";
|
||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
interface CategoryOption {
|
||||
id: string;
|
||||
name: string;
|
||||
subCategoryAttributes?: Array<{ id: string; paramName: string }>;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
export default function ProductCategoryPage() {
|
||||
const router = useRouter();
|
||||
const { draft, setDraft } = useProductDraft();
|
||||
const { submit, submitting } = useProductSubmit();
|
||||
const { t } = useLanguage();
|
||||
const c = t.dashboard.productNew.category;
|
||||
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
||||
const [subCategories, setSubCategories] = useState<CategoryOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingSubcategories, setLoadingSubcategories] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCategories() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/products/categories?size=100", {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok || (result?.responseCode && result.responseCode !== "0000")) {
|
||||
throw new Error(result?.responseDesc || c.errorLoadCat);
|
||||
}
|
||||
|
||||
setCategories(Array.isArray(result?.rows) ? result.rows : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : c.errorLoadCat);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSubCategories() {
|
||||
if (!draft.categoryId) {
|
||||
setSubCategories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSubcategories(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/products/subcategories/${draft.categoryId}?size=100`, {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok || (result?.responseCode && result.responseCode !== "0000")) {
|
||||
throw new Error(result?.responseDesc || c.errorLoadSub);
|
||||
}
|
||||
|
||||
setSubCategories(Array.isArray(result?.rows) ? result.rows : []);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : c.errorLoadSub
|
||||
);
|
||||
} finally {
|
||||
setLoadingSubcategories(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSubCategories();
|
||||
}, [draft.categoryId]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<div className="lg:col-span-7">
|
||||
<div className="bg-surface-container-lowest p-8 md:p-10 rounded-2xl border border-outline-variant/10 shadow-sm">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-primary mb-3">
|
||||
{c.mainCategory}
|
||||
</label>
|
||||
<select
|
||||
value={draft.categoryId}
|
||||
onChange={(ev) => {
|
||||
const selected = categories.find((cat) => cat.id === ev.target.value);
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
categoryId: ev.target.value,
|
||||
categoryName: selected?.name || "",
|
||||
subCategoryId: "",
|
||||
subCategoryName: "",
|
||||
categoryInformations: [],
|
||||
}));
|
||||
}}
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-surface-container-low border-none px-5 py-5 font-semibold text-on-surface focus:ring-2 focus:ring-primary/10 disabled:opacity-60"
|
||||
>
|
||||
<option value="">
|
||||
{loading ? c.loadingCategories : c.selectMain}
|
||||
</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-3">
|
||||
{c.subCategory}
|
||||
</label>
|
||||
<select
|
||||
value={draft.subCategoryId}
|
||||
onChange={(ev) => {
|
||||
const selected = subCategories.find((sc) => sc.id === ev.target.value);
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
subCategoryId: ev.target.value,
|
||||
subCategoryName: selected?.name || "",
|
||||
subCategoryAttributes: selected?.subCategoryAttributes || [],
|
||||
categoryInformations: [],
|
||||
}));
|
||||
}}
|
||||
disabled={!draft.categoryId || loadingSubcategories}
|
||||
className="w-full rounded-xl bg-surface-container-low border-none px-5 py-5 font-semibold text-on-surface focus:ring-2 focus:ring-primary/10 disabled:opacity-60"
|
||||
>
|
||||
<option value="">
|
||||
{!draft.categoryId
|
||||
? c.awaitingMain
|
||||
: loadingSubcategories
|
||||
? c.loadingSubcategories
|
||||
: c.selectSub}
|
||||
</option>
|
||||
{subCategories.map((subCategory) => (
|
||||
<option key={subCategory.id} value={subCategory.id}>
|
||||
{subCategory.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-error/20 bg-error-container p-4 text-sm font-semibold text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5">
|
||||
<div className="rounded-2xl overflow-hidden border border-outline-variant/10 bg-primary text-white p-8 min-h-[240px] flex flex-col justify-end">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-white/70 mb-3">
|
||||
{c.title}
|
||||
</p>
|
||||
<p className="text-3xl font-black font-headline tracking-tight leading-tight">
|
||||
{c.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Bottom Footer */}
|
||||
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={async () => { try { await submit("DRAFT"); router.push("/products"); } catch {} }}
|
||||
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
|
||||
{submitting ? t.dashboard.productNew.category.saveDraft : c.saveDraft}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{c.autoSaved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/products"
|
||||
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{c.cancel}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/products/new/details")}
|
||||
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
|
||||
>
|
||||
{c.next}
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
src/app/(dashboard)/products/new/details/page.tsx
Normal file
432
src/app/(dashboard)/products/new/details/page.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProductDraft } from "@/lib/product-draft";
|
||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const MAX_IMAGES = 8;
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function ImageSlotItem({
|
||||
fileId,
|
||||
slotNumber,
|
||||
onUploaded,
|
||||
onRemove,
|
||||
}: {
|
||||
fileId: string;
|
||||
slotNumber: number;
|
||||
onUploaded: (fileId: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
const pe = t.dashboard.productEdit;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
||||
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPreviewUrl(objectUrl);
|
||||
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onUploaded(id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload gagal");
|
||||
setPreviewUrl("");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const hasImage = fileId || previewUrl;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-surface-container-low border border-outline-variant/10">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg bg-surface-container flex items-center justify-center flex-shrink-0 overflow-hidden cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-outlined text-2xl"
|
||||
style={{
|
||||
fontVariationSettings: hasImage ? "'FILL' 1" : "'FILL' 0",
|
||||
color: hasImage ? "var(--color-primary)" : "var(--color-outline)",
|
||||
}}
|
||||
>
|
||||
image
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
Image {slotNumber}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-on-surface truncate mt-0.5">
|
||||
{hasImage ? pe.uploaded : pe.noImage}
|
||||
</p>
|
||||
{error ? <p className="text-[10px] text-error mt-0.5">{error}</p> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-3 py-1.5 rounded-lg bg-primary text-white text-[10px] font-black uppercase tracking-wider disabled:opacity-60"
|
||||
>
|
||||
{uploading ? "..." : hasImage ? pe.changeImage : "Upload"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-error hover:bg-error-container transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept="image/*" onChange={handleChange} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { draft, setDraft } = useProductDraft();
|
||||
const { submit, submitting } = useProductSubmit();
|
||||
const { t } = useLanguage();
|
||||
const d = t.dashboard.productNew.details;
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
|
||||
// slotsCount tracks how many image slots to show (starts from existing draft state)
|
||||
const [slotsCount, setSlotsCount] = useState(() => {
|
||||
return (draft.imageId ? 1 : 0) + draft.productImages.length;
|
||||
});
|
||||
|
||||
function getSlotFileId(i: number): string {
|
||||
if (i === 0) return draft.imageId;
|
||||
return draft.productImages[i - 1] || "";
|
||||
}
|
||||
|
||||
function handleImageUploaded(i: number, fileId: string) {
|
||||
if (i === 0) {
|
||||
setDraft((prev) => ({ ...prev, imageId: fileId }));
|
||||
} else {
|
||||
setDraft((prev) => {
|
||||
const updated = [...prev.productImages];
|
||||
while (updated.length < i) updated.push("");
|
||||
updated[i - 1] = fileId;
|
||||
return { ...prev, productImages: updated };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageRemove(i: number) {
|
||||
if (i === 0) {
|
||||
const [first = "", ...rest] = draft.productImages;
|
||||
setDraft((prev) => ({ ...prev, imageId: first, productImages: rest }));
|
||||
} else {
|
||||
setDraft((prev) => {
|
||||
const updated = [...prev.productImages];
|
||||
updated.splice(i - 1, 1);
|
||||
return { ...prev, productImages: updated };
|
||||
});
|
||||
}
|
||||
setSlotsCount((c) => Math.max(0, c - 1));
|
||||
}
|
||||
|
||||
function addImageSlot() {
|
||||
if (slotsCount >= MAX_IMAGES) return;
|
||||
const newIndex = slotsCount; // 0-based index of the new slot
|
||||
if (newIndex >= 1) {
|
||||
// Adding a gallery slot — reserve the space in productImages
|
||||
setDraft((prev) => ({ ...prev, productImages: [...prev.productImages, ""] }));
|
||||
}
|
||||
// If newIndex === 0, it's the main image slot — imageId is already "" in draft
|
||||
setSlotsCount((c) => c + 1);
|
||||
}
|
||||
|
||||
function addKeyword() {
|
||||
const value = keywordInput.trim();
|
||||
if (!value) return;
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
keywords: prev.keywords.includes(value) ? prev.keywords : [...prev.keywords, value],
|
||||
}));
|
||||
setKeywordInput("");
|
||||
}
|
||||
|
||||
function updateFeature(index: number, value: string) {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
features: prev.features.map((f, i) => (i === index ? value : f)),
|
||||
}));
|
||||
}
|
||||
|
||||
function removeFeature(index: number) {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
features: prev.features.filter((_, i) => i !== index),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||||
<div className="xl:col-span-7 space-y-8">
|
||||
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-6">
|
||||
<div>
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
|
||||
{d.officialName}
|
||||
</label>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={d.officialNamePlaceholder}
|
||||
className="w-full bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-1 flex items-center justify-between p-4 rounded-xl bg-surface-container-low">
|
||||
<div>
|
||||
<p className="text-sm font-bold">{d.preOrder}</p>
|
||||
<p className="text-[11px] text-on-surface-variant">{d.preOrderSub}</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.isPreOrder}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, isPreOrder: e.target.checked }))}
|
||||
className="h-5 w-5 rounded border-outline-variant"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-center justify-between p-4 rounded-xl bg-surface-container-low">
|
||||
<div>
|
||||
<p className="text-sm font-bold">{d.brandNew}</p>
|
||||
<p className="text-[11px] text-on-surface-variant">{d.brandNewSub}</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.isNew}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, isNew: e.target.checked }))}
|
||||
className="h-5 w-5 rounded border-outline-variant"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
|
||||
{d.preOrderDay}
|
||||
</label>
|
||||
<input
|
||||
value={draft.preOrderDay}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, preOrderDay: e.target.value }))}
|
||||
placeholder="14"
|
||||
className="w-full bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
|
||||
{d.narrative}
|
||||
</label>
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
|
||||
rows={6}
|
||||
placeholder={d.narrativePlaceholder}
|
||||
className="w-full bg-surface-container-low border-none rounded-xl p-5 font-medium focus:ring-2 focus:ring-primary/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">
|
||||
{d.keywords}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addKeyword();
|
||||
}
|
||||
}}
|
||||
placeholder={d.addKeyword}
|
||||
className="flex-1 bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyword}
|
||||
className="px-5 py-3 rounded-xl bg-primary text-white font-black text-sm uppercase tracking-[0.12em]"
|
||||
>
|
||||
{d.addKeywordBtn}
|
||||
</button>
|
||||
</div>
|
||||
{draft.keywords.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{draft.keywords.map((keyword) => (
|
||||
<button
|
||||
key={keyword}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
keywords: prev.keywords.filter((k) => k !== keyword),
|
||||
}))
|
||||
}
|
||||
className="rounded-full bg-primary/5 px-3 py-2 text-xs font-black text-primary"
|
||||
>
|
||||
{keyword} ×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{d.features}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, features: [...prev.features, ""] }))}
|
||||
className="text-sm font-black text-primary"
|
||||
>
|
||||
{d.addFeature}
|
||||
</button>
|
||||
</div>
|
||||
{draft.features.length === 0 && (
|
||||
<p className="text-sm text-on-surface-variant font-medium py-2">
|
||||
{d.noFeatures}
|
||||
</p>
|
||||
)}
|
||||
{draft.features.map((feature, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<input
|
||||
value={feature}
|
||||
onChange={(e) => updateFeature(index, e.target.value)}
|
||||
placeholder="e.g. Premium finishing"
|
||||
className="flex-1 bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFeature(index)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors flex-shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-5 space-y-8">
|
||||
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline block">
|
||||
{d.visualIdentity}
|
||||
</label>
|
||||
<span className="text-[10px] font-bold text-outline">
|
||||
{slotsCount} / {MAX_IMAGES}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{slotsCount === 0 && (
|
||||
<p className="text-sm text-on-surface-variant font-medium py-2">
|
||||
{d.noImages}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: slotsCount }, (_, i) => (
|
||||
<ImageSlotItem
|
||||
key={i}
|
||||
fileId={getSlotFileId(i)}
|
||||
slotNumber={i + 1}
|
||||
onUploaded={(fileId) => handleImageUploaded(i, fileId)}
|
||||
onRemove={() => handleImageRemove(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{slotsCount < MAX_IMAGES && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addImageSlot}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl border-2 border-dashed border-outline-variant/40 text-on-surface-variant hover:border-primary hover:text-primary transition-colors text-sm font-bold"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">add</span>
|
||||
{d.addImage}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Bottom Footer */}
|
||||
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={async () => { try { await submit("DRAFT"); router.push("/products"); } catch {} }}
|
||||
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
|
||||
{submitting ? d.saveDraft : d.saveDraft}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{d.autoSaved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/products/new/category")}
|
||||
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
|
||||
{d.back}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/products/new/pricing")}
|
||||
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
|
||||
>
|
||||
{d.next}
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/app/(dashboard)/products/new/layout.tsx
Normal file
141
src/app/(dashboard)/products/new/layout.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ProductDraftProvider, useProductDraft } from "@/lib/product-draft";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const stepHrefs = [
|
||||
"/products/new/category",
|
||||
"/products/new/details",
|
||||
"/products/new/pricing",
|
||||
"/products/new/specifications",
|
||||
"/products/new/review",
|
||||
];
|
||||
const stepIndexes = ["01", "02", "03", "04", "05"];
|
||||
|
||||
function ProductWizardLayoutInner({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { resetDraft } = useProductDraft();
|
||||
const { t } = useLanguage();
|
||||
const n = t.dashboard.productNew;
|
||||
|
||||
const steps = stepHrefs.map((href, i) => ({
|
||||
href,
|
||||
label: n.layout.stepLabels[i],
|
||||
index: stepIndexes[i],
|
||||
}));
|
||||
|
||||
function handleCancel() {
|
||||
resetDraft();
|
||||
router.push("/products");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<div className="w-full px-6 md:px-10 py-8 pb-36">
|
||||
<div className="mb-10">
|
||||
<nav className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-outline mb-4">
|
||||
<span>{n.layout.breadcrumbProducts}</span>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
chevron_right
|
||||
</span>
|
||||
<span>{n.layout.breadcrumbEditor}</span>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
chevron_right
|
||||
</span>
|
||||
<span className="text-primary">{n.layout.breadcrumbNew}</span>
|
||||
</nav>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-black font-headline tracking-tighter text-on-surface">
|
||||
{n.layout.pageTitle}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-on-surface-variant font-medium">
|
||||
{n.layout.pageSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="text-sm font-black uppercase tracking-[0.18em] text-outline hover:text-primary transition-colors"
|
||||
>
|
||||
{n.layout.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 overflow-x-auto">
|
||||
<div className="min-w-[920px] bg-surface-container-lowest rounded-2xl border border-outline-variant/10 p-2 flex items-center justify-between gap-2">
|
||||
{steps.map((step) => {
|
||||
const isActive = pathname === step.href;
|
||||
const isDone =
|
||||
steps.findIndex((item) => item.href === pathname) >
|
||||
steps.findIndex((item) => item.href === step.href);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.href}
|
||||
className={`flex-1 flex items-center justify-center gap-3 px-4 py-4 rounded-xl transition-all ${
|
||||
isActive
|
||||
? "editorial-gradient shadow-lg shadow-primary/20"
|
||||
: isDone
|
||||
? "bg-tertiary/10"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-black ${
|
||||
isActive
|
||||
? "bg-white text-primary"
|
||||
: isDone
|
||||
? "bg-tertiary text-white"
|
||||
: "bg-surface-container-low text-outline"
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
check
|
||||
</span>
|
||||
) : (
|
||||
step.index
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-black uppercase tracking-[0.18em] whitespace-nowrap ${
|
||||
isActive
|
||||
? "text-white"
|
||||
: isDone
|
||||
? "text-tertiary"
|
||||
: "text-outline"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductWizardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ProductDraftProvider>
|
||||
<ProductWizardLayoutInner>{children}</ProductWizardLayoutInner>
|
||||
</ProductDraftProvider>
|
||||
);
|
||||
}
|
||||
1085
src/app/(dashboard)/products/new/pricing/page.tsx
Normal file
1085
src/app/(dashboard)/products/new/pricing/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
407
src/app/(dashboard)/products/new/review/page.tsx
Normal file
407
src/app/(dashboard)/products/new/review/page.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProductDraft } from "@/lib/product-draft";
|
||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function toNumber(value: string) {
|
||||
const normalized = value.replace(/\./g, "").replace(/,/g, ".");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatIDR(value: string) {
|
||||
const num = toNumber(value);
|
||||
if (!num) return "-";
|
||||
return new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0 }).format(num);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.18em] text-outline mb-3 pb-2 border-b border-surface-container">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, yes, no }: { label: string; value?: string | number | boolean | null; yes: string; no: string }) {
|
||||
if (value === "" || value === undefined || value === null) return null;
|
||||
const display =
|
||||
typeof value === "boolean"
|
||||
? value ? yes : no
|
||||
: String(value);
|
||||
return (
|
||||
<div className="flex justify-between gap-4 py-1.5 text-sm">
|
||||
<span className="text-on-surface-variant font-medium flex-shrink-0">{label}</span>
|
||||
<span className="font-semibold text-on-surface text-right">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-bold">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductReviewPage() {
|
||||
const router = useRouter();
|
||||
const { draft } = useProductDraft();
|
||||
const { submit, submitting, error, errorLog, setError } = useProductSubmit();
|
||||
const { t } = useLanguage();
|
||||
const r = t.dashboard.productNew.review;
|
||||
const [errorLogCopied, setErrorLogCopied] = useState(false);
|
||||
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
async function loadWarehouses() {
|
||||
try {
|
||||
const res = await fetch("/api/products/warehouses?size=100", {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
});
|
||||
const result = await res.json();
|
||||
const rows: Array<{ id: string; name: string; address: string }> =
|
||||
Array.isArray(result?.rows) ? result.rows :
|
||||
Array.isArray(result?.data) ? result.data : [];
|
||||
const map: Record<string, string> = {};
|
||||
for (const w of rows) {
|
||||
map[w.id] = w.name || w.address || w.id;
|
||||
}
|
||||
setWarehouseMap(map);
|
||||
} catch {
|
||||
// silently ignore — fallback to ID display
|
||||
}
|
||||
}
|
||||
loadWarehouses();
|
||||
}, []);
|
||||
|
||||
async function handleSaveDraft() {
|
||||
setErrorLogCopied(false);
|
||||
try {
|
||||
await submit("DRAFT");
|
||||
router.push("/products");
|
||||
} catch {
|
||||
// error is set by the hook
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitForReview() {
|
||||
setErrorLogCopied(false);
|
||||
try {
|
||||
await submit("PUBLISHED");
|
||||
router.push("/products/new/submitted");
|
||||
} catch {
|
||||
// error is set by the hook
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black font-headline tracking-tight">{r.title}</h2>
|
||||
<p className="text-sm text-on-surface-variant mt-1">
|
||||
{r.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
|
||||
<SectionTitle>01 · {r.section01}</SectionTitle>
|
||||
<Row label={r.category} value={draft.categoryName || draft.categoryId || "-"} yes={r.yes} no={r.no} />
|
||||
<Row label={r.subCategory} value={draft.subCategoryName || draft.subCategoryId || "-"} yes={r.yes} no={r.no} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
|
||||
<SectionTitle>02 · {r.section02}</SectionTitle>
|
||||
<Row label={r.productName} value={draft.name || "-"} yes={r.yes} no={r.no} />
|
||||
<Row label={r.description} value={draft.description || "-"} yes={r.yes} no={r.no} />
|
||||
<Row label={r.preOrder} value={draft.isPreOrder} yes={r.yes} no={r.no} />
|
||||
{draft.isPreOrder && <Row label={r.preOrderDay} value={draft.preOrderDay} yes={r.yes} no={r.no} />}
|
||||
<Row label={r.brandNew} value={draft.isNew} yes={r.yes} no={r.no} />
|
||||
<Row label={r.eligibleToExport} value={draft.isEligibleToExport} yes={r.yes} no={r.no} />
|
||||
|
||||
{draft.keywords.filter(Boolean).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.keywords}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{draft.keywords.filter(Boolean).map((k) => <Badge key={k}>{k}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.features.filter(Boolean).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.features}</p>
|
||||
<ul className="space-y-1">
|
||||
{draft.features.filter(Boolean).map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-on-surface">
|
||||
<span className="material-symbols-outlined text-primary text-sm mt-0.5">check</span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
{draft.imageId && (
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
|
||||
<span className="material-symbols-outlined text-primary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>image</span>
|
||||
{r.mainImage}
|
||||
</div>
|
||||
)}
|
||||
{draft.productImages.filter(Boolean).length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
|
||||
<span className="material-symbols-outlined text-secondary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>photo_library</span>
|
||||
{draft.productImages.filter(Boolean).length} {r.gallery}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing & Models */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
|
||||
<SectionTitle>03 · {r.section03} ({draft.models.length} {r.model})</SectionTitle>
|
||||
<div className="space-y-5">
|
||||
{draft.models.map((model, idx) => (
|
||||
<div key={model.id} className="rounded-xl border border-outline-variant/10 bg-surface-container-low p-4 space-y-3">
|
||||
{/* Model header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white text-[10px] font-black">{idx + 1}</div>
|
||||
<p className="text-sm font-black text-on-surface">{model.name || `Model ${idx + 1}`}</p>
|
||||
{model.sku && <span className="text-[10px] text-outline font-bold">SKU: {model.sku}</span>}
|
||||
</div>
|
||||
|
||||
{/* Model core info */}
|
||||
<div className="grid grid-cols-2 gap-x-6">
|
||||
<Row label={r.price} value={`${model.currency || "IDR"} ${formatIDR(model.price)}`} yes={r.yes} no={r.no} />
|
||||
<Row label={`${r.weight} (${model.weightType || "G"})`} value={model.weight ? `${model.weight}` : undefined} yes={r.yes} no={r.no} />
|
||||
<Row label={`${r.dimensions} (${model.dimensionType || "CM"})`} value={[model.length, model.width, model.height].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
|
||||
{model.hasPromotion && <Row label={r.promoPrice} value={`${model.promotionCurrency || model.currency || "IDR"} ${formatIDR(model.promotionPrice)}`} yes={r.yes} no={r.no} />}
|
||||
{model.hasPromotion && model.promotionStartDate && (
|
||||
<Row label={r.promoPeriod} value={`${model.promotionStartDate} → ${model.promotionEndDate}`} yes={r.yes} no={r.no} />
|
||||
)}
|
||||
<Row label={`${r.packagingWeight} (${model.packagingWeightType || "G"})`} value={model.packagingWeight || undefined} yes={r.yes} no={r.no} />
|
||||
<Row label={`${r.packagingDimensions} (${model.packagingDimensionType || "CM"})`} value={[model.packagingLength, model.packagingWidth, model.packagingHeight].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
|
||||
</div>
|
||||
|
||||
{/* Warehouse stock */}
|
||||
{model.warehouses.filter((w) => w.id).length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-1">{r.warehouseStock}</p>
|
||||
{model.warehouses.filter((w) => w.id).map((w, wi) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
|
||||
<span>{warehouseMap[w.id] || w.name || `${w.id.slice(0, 8)}...`}</span>
|
||||
<span className="font-bold">{w.stock} {r.unit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Measurements / Variants */}
|
||||
{model.measurements.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-2">
|
||||
{r.measurements} ({model.measurements.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{model.measurements.map((ms, mi) => (
|
||||
<div key={ms.id} className="rounded-lg bg-surface-container-lowest border border-outline-variant/10 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-black text-primary uppercase tracking-wider">
|
||||
{ms.measurementType || `${r.variantLabel} ${mi + 1}`}
|
||||
</span>
|
||||
{ms.measurementValue && (
|
||||
<span className="text-[10px] font-bold text-outline bg-surface-container px-2 py-0.5 rounded-full">
|
||||
{ms.measurementValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-6">
|
||||
<Row label={r.price} value={`${ms.currency || "IDR"} ${formatIDR(ms.price)}`} yes={r.yes} no={r.no} />
|
||||
<Row label={`${r.weight} (${ms.weightType || "G"})`} value={ms.weight || undefined} yes={r.yes} no={r.no} />
|
||||
<Row label={`${r.dimensions} (${ms.dimensionType || "CM"})`} value={[ms.length, ms.width, ms.height].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
|
||||
{ms.hasPromotion && <Row label={r.promoPrice} value={`${ms.promotionCurrency || ms.currency || "IDR"} ${formatIDR(ms.promotionPrice)}`} yes={r.yes} no={r.no} />}
|
||||
{ms.hasPromotion && ms.promotionStartDate && (
|
||||
<Row label={r.promoPeriod} value={`${ms.promotionStartDate} → ${ms.promotionEndDate}`} yes={r.yes} no={r.no} />
|
||||
)}
|
||||
<Row label={`${r.packagingWeight} (${ms.packagingWeightType || "G"})`} value={ms.packagingWeight || undefined} yes={r.yes} no={r.no} />
|
||||
</div>
|
||||
{ms.warehouses.filter((w) => w.id).length > 0 && (
|
||||
<div className="mt-1.5">
|
||||
<p className="text-[9px] text-outline font-bold uppercase tracking-widest mb-1">{r.stock}</p>
|
||||
{ms.warehouses.filter((w) => w.id).map((w, wi) => (
|
||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
|
||||
<span>{warehouseMap[w.id] || w.name || `${w.id.slice(0, 8)}...`}</span>
|
||||
<span className="font-bold">{w.stock} {r.unit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specifications */}
|
||||
{(draft.productInformations.filter((i) => i.paramName && i.paramValue).length > 0 ||
|
||||
draft.categoryInformations.filter((i) => i.paramName && i.paramValue).length > 0) && (
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
|
||||
<SectionTitle>04 · {r.section04}</SectionTitle>
|
||||
{draft.productInformations.filter((i) => i.paramName && i.paramValue).length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.productInfo}</p>
|
||||
{draft.productInformations.filter((i) => i.paramName && i.paramValue).map((item, i) => (
|
||||
<Row key={i} label={item.paramName} value={item.paramValue} yes={r.yes} no={r.no} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{draft.categoryInformations.filter((i) => i.paramName && i.paramValue).length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.categoryInfo}</p>
|
||||
{draft.categoryInformations.filter((i) => i.paramName && i.paramValue).map((item, i) => (
|
||||
<Row key={i} label={item.paramName} value={item.paramValue} yes={r.yes} no={r.no} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.compliance}</p>
|
||||
<Row label={r.countryOfOrigin} value={draft.complianceInformation.countryOfOrigin} yes={r.yes} no={r.no} />
|
||||
<Row label={r.safetyWarning} value={draft.complianceInformation.safetyWarning} yes={r.yes} no={r.no} />
|
||||
<Row label={r.dangerousGoods} value={draft.complianceInformation.isDangerousGoodRegulation} yes={r.yes} no={r.no} />
|
||||
{draft.complianceInformation.fileId && (
|
||||
<div className="flex justify-between gap-4 py-1.5 text-sm">
|
||||
<span className="text-on-surface-variant font-medium flex-shrink-0">{r.msds}</span>
|
||||
<span className="flex items-center gap-1.5 font-semibold text-primary text-right">
|
||||
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>description</span>
|
||||
<span className="font-mono text-xs truncate max-w-[200px]">{draft.complianceInformation.fileId}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(draft.warrantyInformation.type || draft.warrantyInformation.duration) && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.warranty}</p>
|
||||
<Row label={r.warrantyType} value={draft.warrantyInformation.type} yes={r.yes} no={r.no} />
|
||||
<Row
|
||||
label={r.warrantyDuration}
|
||||
value={draft.warrantyInformation.duration
|
||||
? `${draft.warrantyInformation.duration} ${draft.warrantyInformation.durationType}`
|
||||
: undefined}
|
||||
yes={r.yes}
|
||||
no={r.no}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supporting Documents */}
|
||||
{(draft.productFiles ?? []).length > 0 && (
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6">
|
||||
<SectionTitle>05 · {r.section05}</SectionTitle>
|
||||
<div className="space-y-2">
|
||||
{(draft.productFiles ?? []).map((file) => (
|
||||
<div key={file.id} className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
|
||||
<span className="material-symbols-outlined text-primary text-[18px]" style={{ fontVariationSettings: "'FILL' 1" }}>description</span>
|
||||
<span className="flex-1 text-sm font-semibold text-on-surface truncate">{file.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fixed Bottom Footer */}
|
||||
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
|
||||
<div className="w-full space-y-3">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-error text-[18px] flex-shrink-0">error</span>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{errorLog && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(errorLog, null, 2));
|
||||
setErrorLogCopied(true);
|
||||
setTimeout(() => setErrorLogCopied(false), 2000);
|
||||
}}
|
||||
className="flex-shrink-0 flex items-center gap-1.5 text-[11px] font-black uppercase tracking-widest bg-error/10 hover:bg-error/20 text-error border border-error/30 rounded-lg px-3 py-1.5 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{errorLogCopied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{errorLogCopied ? r.copied : r.copyErrorLog}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{submitting && (
|
||||
<div className="flex items-center gap-2 text-xs text-on-surface-variant font-semibold">
|
||||
<div className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
{r.saving}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
|
||||
{r.saveDraft}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{r.autoSaved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/products/new/specifications")}
|
||||
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
|
||||
{r.back}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitForReview}
|
||||
disabled={submitting}
|
||||
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0 flex items-center gap-2"
|
||||
>
|
||||
{submitting ? r.submitting : r.submit}
|
||||
{!submitting && <span className="material-symbols-outlined text-[18px]">send</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
541
src/app/(dashboard)/products/new/specifications/page.tsx
Normal file
541
src/app/(dashboard)/products/new/specifications/page.tsx
Normal file
@ -0,0 +1,541 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProductDraft } from "@/lib/product-draft";
|
||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
const GENERAL_INFO_FIELDS = [
|
||||
{ key: "Brand", label: "Brand", required: true, colSpan: 2, placeholder: "e.g. Sterling Elite" },
|
||||
{ key: "Manufacturer", label: "Manufacturer", required: false, colSpan: 1, placeholder: "Official Entity" },
|
||||
{ key: "Color", label: "Color", required: false, colSpan: 1, placeholder: "e.g. Obsidian" },
|
||||
{ key: "Material", label: "Material", required: false, colSpan: 2, placeholder: "e.g. Aerospace Grade Aluminum" },
|
||||
] as const;
|
||||
|
||||
function useFileUpload(onSuccess: (fileId: string, fileName: string) => void) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function upload(file: File) {
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
const id = data?.data?.fileId || data?.data?.id || data?.fileId || data?.id;
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onSuccess(id, file.name);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload gagal");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { upload, uploading, error };
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-medium text-on-surface focus:ring-2 focus:ring-primary/10 focus:bg-surface-container-lowest transition-all outline-none placeholder:text-on-surface-variant/40";
|
||||
const labelClass =
|
||||
"block text-[10px] font-black uppercase tracking-widest text-on-surface/60 mb-1.5";
|
||||
const sectionClass =
|
||||
"bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-6";
|
||||
const sectionIconClass =
|
||||
"w-10 h-10 rounded-xl bg-primary/5 flex items-center justify-center text-primary flex-shrink-0";
|
||||
|
||||
export default function ProductSpecificationsPage() {
|
||||
const router = useRouter();
|
||||
const { draft, setDraft } = useProductDraft();
|
||||
const { submit, submitting } = useProductSubmit();
|
||||
const { t } = useLanguage();
|
||||
const s = t.dashboard.productNew.specifications;
|
||||
|
||||
const msdsInputRef = useRef<HTMLInputElement>(null);
|
||||
const docInputRef = useRef<HTMLInputElement>(null);
|
||||
const [msdsName, setMsdsName] = useState("");
|
||||
|
||||
// --- productInformations helpers ---
|
||||
function getProductInfo(key: string) {
|
||||
return draft.productInformations.find((i) => i.paramName === key)?.paramValue ?? "";
|
||||
}
|
||||
function setProductInfo(key: string, value: string) {
|
||||
setDraft((prev) => {
|
||||
const exists = prev.productInformations.some((i) => i.paramName === key);
|
||||
if (exists) {
|
||||
return {
|
||||
...prev,
|
||||
productInformations: prev.productInformations.map((i) =>
|
||||
i.paramName === key ? { ...i, paramValue: value } : i
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
productInformations: [...prev.productInformations, { paramName: key, paramValue: value }],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- categoryInformations helpers ---
|
||||
function getCategoryInfo(paramName: string) {
|
||||
return draft.categoryInformations.find((i) => i.paramName === paramName)?.paramValue ?? "";
|
||||
}
|
||||
function setCategoryInfo(paramName: string, value: string) {
|
||||
setDraft((prev) => {
|
||||
const exists = prev.categoryInformations.some((i) => i.paramName === paramName);
|
||||
if (exists) {
|
||||
return {
|
||||
...prev,
|
||||
categoryInformations: prev.categoryInformations.map((i) =>
|
||||
i.paramName === paramName ? { ...i, paramValue: value } : i
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
categoryInformations: [...prev.categoryInformations, { paramName: paramName, paramValue: value }],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- MSDS Upload ---
|
||||
const { upload: uploadMsds, uploading: uploadingMsds, error: msdsError } = useFileUpload(
|
||||
(id, name) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
complianceInformation: { ...prev.complianceInformation, fileId: id },
|
||||
}));
|
||||
setMsdsName(name);
|
||||
}
|
||||
);
|
||||
|
||||
// --- Supporting Docs Upload ---
|
||||
const { upload: uploadDoc, uploading: uploadingDoc, error: docError } = useFileUpload(
|
||||
(id, name) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
productFiles: [...(prev.productFiles ?? []), { id, name }],
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
function removeDoc(id: string) {
|
||||
setDraft((prev) => ({ ...prev, productFiles: (prev.productFiles ?? []).filter((f) => f.id !== id) }));
|
||||
}
|
||||
|
||||
const subCategoryAttributes = draft.subCategoryAttributes ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-32">
|
||||
|
||||
{/* 1. General Information */}
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={sectionIconClass}>
|
||||
<span className="material-symbols-outlined">description</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.generalInfo}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{GENERAL_INFO_FIELDS.map((field) => (
|
||||
<div key={field.key} className={field.colSpan === 2 ? "md:col-span-2" : ""}>
|
||||
<label className={labelClass}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-error ml-0.5">*</span>}
|
||||
</label>
|
||||
<input
|
||||
value={getProductInfo(field.key)}
|
||||
onChange={(e) => setProductInfo(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 2. Category Information */}
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={sectionIconClass}>
|
||||
<span className="material-symbols-outlined">category</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.categoryInfo}</h3>
|
||||
</div>
|
||||
{subCategoryAttributes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{subCategoryAttributes.map((attr) => (
|
||||
<div key={attr.id}>
|
||||
<label className={labelClass}>{attr.paramName}</label>
|
||||
<input
|
||||
value={getCategoryInfo(attr.paramName)}
|
||||
onChange={(e) => setCategoryInfo(attr.paramName, e.target.value)}
|
||||
placeholder={`e.g. ${attr.paramName}...`}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-3 text-sm text-on-surface-variant">
|
||||
<span className="material-symbols-outlined text-outline text-[20px]">info</span>
|
||||
<span>{s.awaitingCategory}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 3. Compliance */}
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={sectionIconClass}>
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.compliance}</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>{s.safetyWarning}</label>
|
||||
<textarea
|
||||
value={draft.complianceInformation.safetyWarning}
|
||||
onChange={(e) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
complianceInformation: { ...prev.complianceInformation, safetyWarning: e.target.value },
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
placeholder="Enter all safety precautions..."
|
||||
className="w-full bg-surface-container-low rounded-xl border-none px-4 py-3 text-sm font-medium text-on-surface focus:ring-2 focus:ring-primary/10 resize-none transition-all outline-none placeholder:text-on-surface-variant/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
|
||||
<div>
|
||||
<label className={labelClass}>{s.countryOfOrigin}</label>
|
||||
<input
|
||||
value={draft.complianceInformation.countryOfOrigin}
|
||||
onChange={(e) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
complianceInformation: { ...prev.complianceInformation, countryOfOrigin: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Indonesia"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className={labelClass}>{s.dangerousGoods}</p>
|
||||
<div className="flex bg-surface-container-low p-1 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
complianceInformation: { ...prev.complianceInformation, isDangerousGoodRegulation: true },
|
||||
}))
|
||||
}
|
||||
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||
draft.complianceInformation.isDangerousGoodRegulation
|
||||
? "bg-white shadow-sm text-primary"
|
||||
: "text-on-surface-variant hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{s.yes}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
complianceInformation: { ...prev.complianceInformation, isDangerousGoodRegulation: false },
|
||||
}))
|
||||
}
|
||||
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||
!draft.complianceInformation.isDangerousGoodRegulation
|
||||
? "bg-white shadow-sm text-primary"
|
||||
: "text-on-surface-variant hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{s.no}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MSDS Upload */}
|
||||
<div>
|
||||
<p className={labelClass}>{s.msds}</p>
|
||||
<input
|
||||
ref={msdsInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) uploadMsds(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{draft.complianceInformation.fileId ? (
|
||||
<div className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">description</span>
|
||||
<span className="flex-1 text-sm font-semibold text-on-surface truncate">
|
||||
{msdsName || draft.complianceInformation.fileId}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
complianceInformation: { ...prev.complianceInformation, fileId: "" },
|
||||
}));
|
||||
setMsdsName("");
|
||||
}}
|
||||
className="text-outline hover:text-error transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => msdsInputRef.current?.click()}
|
||||
disabled={uploadingMsds}
|
||||
className="w-full border border-dashed border-outline-variant rounded-xl px-4 py-6 flex flex-col items-center justify-center gap-2 bg-surface-container-low/20 hover:bg-surface-container-low/60 transition-all disabled:opacity-60"
|
||||
>
|
||||
{uploadingMsds ? (
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-primary text-2xl">upload_file</span>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold text-on-surface">
|
||||
{uploadingMsds ? t.dashboard.productEdit.uploading : s.msds}
|
||||
</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX up to 15MB</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{msdsError && <p className="text-[11px] text-error mt-1.5 font-semibold">{msdsError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 4. Warranty */}
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={sectionIconClass}>
|
||||
<span className="material-symbols-outlined">verified</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.warranty}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>{s.warrantyType}</label>
|
||||
<input
|
||||
value={draft.warrantyInformation.type}
|
||||
onChange={(e) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
warrantyInformation: { ...prev.warrantyInformation, type: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="e.g. Global Manufacturers Warranty"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{s.warrantyDuration}</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
value={draft.warrantyInformation.duration}
|
||||
onChange={(e) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
warrantyInformation: { ...prev.warrantyInformation, duration: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="0"
|
||||
type="number"
|
||||
min="0"
|
||||
className="w-24 bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-medium focus:ring-2 focus:ring-primary/10 outline-none"
|
||||
/>
|
||||
<select
|
||||
value={draft.warrantyInformation.durationType}
|
||||
onChange={(e) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
warrantyInformation: { ...prev.warrantyInformation, durationType: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="flex-1 bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 outline-none"
|
||||
>
|
||||
<option value="DAY">Days</option>
|
||||
<option value="MONTH">Months</option>
|
||||
<option value="YEAR">Years</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 5. International Export */}
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={sectionIconClass}>
|
||||
<span className="material-symbols-outlined">public</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">International Export</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-surface-container-low/50 p-4 rounded-xl">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-on-surface">Eligible for export?</p>
|
||||
<p className="text-[10px] text-on-surface-variant mt-0.5">Check if the item can be shipped globally</p>
|
||||
</div>
|
||||
<div className="flex bg-surface-container-low p-1 rounded-xl w-36 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, isEligibleToExport: false }))}
|
||||
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||
!draft.isEligibleToExport
|
||||
? "bg-white shadow-sm text-primary"
|
||||
: "text-on-surface-variant hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, isEligibleToExport: true }))}
|
||||
className={`flex-1 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||
draft.isEligibleToExport
|
||||
? "bg-white shadow-sm text-primary"
|
||||
: "text-on-surface-variant hover:text-on-surface"
|
||||
}`}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 6. Product Supporting Documents */}
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={sectionIconClass}>
|
||||
<span className="material-symbols-outlined">folder_open</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">Product Supporting Documents</h3>
|
||||
<p className="text-[10px] text-on-surface-variant mt-0.5">
|
||||
Upload manuals, datasheets, or other supporting files. Multiple documents allowed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
{(draft.productFiles ?? []).map((doc) => (
|
||||
<div key={doc.id} className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">description</span>
|
||||
<span className="flex-1 text-sm font-semibold text-on-surface truncate">{doc.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDoc(doc.id)}
|
||||
className="text-outline hover:text-error transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={docInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xls,.pptx,.zip"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
for (const file of files) await uploadDoc(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => docInputRef.current?.click()}
|
||||
disabled={uploadingDoc}
|
||||
className="w-full mt-3 border border-dashed border-outline-variant rounded-xl px-4 py-7 flex flex-col items-center justify-center gap-2 bg-surface-container-low/20 hover:bg-surface-container-low/60 transition-all disabled:opacity-60"
|
||||
>
|
||||
{uploadingDoc ? (
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-primary text-2xl">cloud_upload</span>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold text-on-surface">
|
||||
{uploadingDoc ? "Uploading..." : "Upload Supporting Documents"}
|
||||
</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX, XLSX, ZIP · Multiple files allowed</p>
|
||||
</div>
|
||||
</button>
|
||||
{docError && <p className="text-[11px] text-error mt-1.5 font-semibold">{docError}</p>}
|
||||
</section>
|
||||
|
||||
{/* Fixed Bottom Footer */}
|
||||
<div className="fixed bottom-0 left-64 right-0 bg-white/90 backdrop-blur-xl border-t border-outline-variant/10 px-10 py-5 z-40">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={async () => { try { await submit("DRAFT"); router.push("/products"); } catch {} }}
|
||||
className="flex items-center gap-2 text-sm font-black text-on-surface-variant hover:text-on-surface transition-colors group disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] group-hover:scale-110 transition-transform">save</span>
|
||||
{submitting ? s.saveDraft : s.saveDraft}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-6 border-l border-outline-variant/20">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-on-surface-variant/60">{s.autoSaved}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/products/new/pricing")}
|
||||
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low hover:border-outline-variant/50 transition-all flex items-center gap-2 group"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] group-hover:-translate-x-1 transition-transform">arrow_back</span>
|
||||
{s.back}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/products/new/review")}
|
||||
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
|
||||
>
|
||||
{s.next}
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/app/(dashboard)/products/new/submitted/page.tsx
Normal file
44
src/app/(dashboard)/products/new/submitted/page.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ProductSubmittedPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-10">
|
||||
<div className="bg-surface-container-lowest rounded-[2rem] border border-outline-variant/10 shadow-sm p-10 md:p-14 text-center">
|
||||
<div className="mx-auto w-28 h-28 rounded-[2rem] bg-primary text-white flex items-center justify-center shadow-2xl shadow-primary/25 mb-10">
|
||||
<span
|
||||
className="material-symbols-outlined text-6xl"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
check_circle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl font-black font-headline tracking-tight text-on-surface mb-5">
|
||||
Product listing flow submitted.
|
||||
</h2>
|
||||
<p className="max-w-2xl mx-auto text-on-surface-variant font-medium leading-relaxed">
|
||||
This confirmation page is still UI-first. Once API integration is
|
||||
attached, this route will be shown after `create product` and
|
||||
`submit-review product` complete successfully.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/products"
|
||||
className="px-7 py-4 rounded-xl bg-primary text-white font-black uppercase tracking-[0.18em] text-sm shadow-lg shadow-primary/20"
|
||||
>
|
||||
Product Listing
|
||||
</Link>
|
||||
<Link
|
||||
href="/products/new/category"
|
||||
className="px-7 py-4 rounded-xl border border-outline-variant/30 text-on-surface font-black uppercase tracking-[0.18em] text-sm hover:bg-surface-container-low transition-colors"
|
||||
>
|
||||
Create Another
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
527
src/app/(dashboard)/products/page.tsx
Normal file
527
src/app/(dashboard)/products/page.tsx
Normal file
@ -0,0 +1,527 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
type TabLabel =
|
||||
| "All Product"
|
||||
| "Draft"
|
||||
| "In Review"
|
||||
| "International Market"
|
||||
| "Local Market"
|
||||
| "Out Of Stock"
|
||||
| "Rejected";
|
||||
|
||||
interface ProductRow {
|
||||
id: string;
|
||||
image: string | null;
|
||||
isFavorite: boolean;
|
||||
market: string;
|
||||
maxPrice: number;
|
||||
minPrice: number;
|
||||
name: string;
|
||||
state?: string | null;
|
||||
status?: string | null;
|
||||
reviewStatus?: string | null;
|
||||
totalStock: number;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function formatPrice(product: ProductRow) {
|
||||
if (!product.minPrice && !product.maxPrice) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat("id-ID");
|
||||
|
||||
if (product.minPrice === product.maxPrice) {
|
||||
return `Rp ${formatter.format(product.minPrice)}`;
|
||||
}
|
||||
|
||||
return `Rp ${formatter.format(product.minPrice)} - ${formatter.format(product.maxPrice)}`;
|
||||
}
|
||||
|
||||
function marketClasses(market: string) {
|
||||
return market === "International"
|
||||
? "bg-secondary-fixed text-on-secondary-fixed"
|
||||
: "bg-tertiary-fixed text-on-tertiary-fixed";
|
||||
}
|
||||
|
||||
function tabFromQuery(tab: string | null): TabLabel {
|
||||
switch (tab) {
|
||||
case "draft":
|
||||
return "Draft";
|
||||
case "in-review":
|
||||
return "In Review";
|
||||
case "international-market":
|
||||
return "International Market";
|
||||
case "local-market":
|
||||
return "Local Market";
|
||||
case "out-of-stock":
|
||||
return "Out Of Stock";
|
||||
case "rejected":
|
||||
return "Rejected";
|
||||
default:
|
||||
return "All Product";
|
||||
}
|
||||
}
|
||||
|
||||
function DeleteConfirmModal({
|
||||
product,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
deleting,
|
||||
d,
|
||||
}: {
|
||||
product: ProductRow;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
deleting: boolean;
|
||||
d: { title: string; message: string; productLabel: string; cancel: string; confirm: string; deleting: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative w-full max-w-md rounded-2xl bg-surface-container-lowest border border-outline-variant/10 shadow-2xl p-8 space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-error-container flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-error text-2xl">delete_forever</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-on-surface font-headline">{d.title}</h2>
|
||||
<p className="text-sm text-on-surface-variant mt-1">{d.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-surface-container-low p-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.productLabel}</p>
|
||||
<p className="text-sm font-bold text-on-surface">{product.name}</p>
|
||||
<p className="text-xs text-outline mt-0.5">ID: {product.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={deleting}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm hover:bg-surface-container-low transition-colors disabled:opacity-50"
|
||||
>
|
||||
{d.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={deleting}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-error text-white font-black text-sm hover:bg-error/90 transition-colors disabled:opacity-60 flex items-center justify-center gap-2"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
|
||||
{d.deleting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-base">delete</span>
|
||||
{d.confirm}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsPageInner() {
|
||||
const { t } = useLanguage();
|
||||
const p = t.dashboard.products;
|
||||
const searchParams = useSearchParams();
|
||||
const tab = searchParams.get("tab");
|
||||
const activeTab = tabFromQuery(tab);
|
||||
|
||||
const [rows, setRows] = useState<ProductRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [totalItem, setTotalItem] = useState(0);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProductRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Reset to page 1 when tab changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProducts() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (tab) params.set("tab", tab);
|
||||
params.set("page", String(page));
|
||||
params.set("size", "20");
|
||||
|
||||
const res = await fetch(`/api/products?${params.toString()}`,
|
||||
{ headers: { "x-auth-token": getToken() } }
|
||||
);
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result?.responseDesc || "Failed to load products");
|
||||
}
|
||||
|
||||
setRows(result?.rows || result?.data?.rows || []);
|
||||
setTotalItem(result?.totalItem || result?.data?.totalItem || 0);
|
||||
setTotalPage(result?.totalPage || result?.data?.totalPage || 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load products");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadProducts();
|
||||
}, [tab, page]);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const isDraft = tab === "draft";
|
||||
const url = `/api/products/${deleteTarget.id}${isDraft ? "?draft=1" : ""}`;
|
||||
await fetch(url, { method: "DELETE", headers: { "x-auth-token": getToken() } });
|
||||
setDeleteTarget(null);
|
||||
window.location.reload();
|
||||
} catch {
|
||||
alert(p.deleteDialog.errorGeneric);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const internationalCount = rows.filter(
|
||||
(row) => row.market === "International"
|
||||
).length;
|
||||
const localCount = rows.filter((row) => row.market === "Local Market").length;
|
||||
|
||||
return (
|
||||
<div className="px-8 pb-12 pt-24">
|
||||
<section className="mb-6 flex flex-col gap-4 md:flex-row">
|
||||
<div className="relative max-w-sm flex-1 overflow-hidden rounded-2xl bg-primary-container p-5 text-white">
|
||||
<div className="relative z-10">
|
||||
<p className="mb-1 text-[10px] font-black uppercase tracking-[0.18em] text-white/70">
|
||||
{p.totalItems}
|
||||
</p>
|
||||
<h1 className="mb-2 font-headline text-4xl font-black tracking-tighter">
|
||||
{totalItem || rows.length}
|
||||
</h1>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-white/20 px-2.5 py-1 text-xs font-bold backdrop-blur">
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
trending_up
|
||||
</span>
|
||||
<span>{p.activeCatalog}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="material-symbols-outlined absolute -bottom-6 -right-6 text-[96px] text-white/10">
|
||||
inventory_2
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-5">
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{p.marketSplit}
|
||||
</p>
|
||||
<h2 className="font-headline text-2xl font-black tracking-tighter text-on-surface">
|
||||
{internationalCount} / {localCount}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mb-2 h-1.5 w-48 overflow-hidden rounded-full bg-surface-container">
|
||||
<div
|
||||
className="h-full bg-tertiary"
|
||||
style={{
|
||||
width: `${rows.length ? (internationalCount / rows.length) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] font-medium text-on-surface-variant">
|
||||
{p.marketSplitDesc}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-[20px] border border-outline-variant/10 bg-surface-container-lowest shadow-sm">
|
||||
<div className="border-b border-surface-container px-4 py-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{p.activeView}
|
||||
</p>
|
||||
<h2 className="mt-1 font-headline text-2xl font-black tracking-tighter text-on-surface">
|
||||
{activeTab}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href="/products/new/category"
|
||||
className="flex items-center gap-2 rounded-xl bg-primary px-5 py-2.5 text-xs font-black text-white transition-transform active:scale-95"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">add</span>
|
||||
{p.addProduct}
|
||||
</Link>
|
||||
<button className="flex h-10 w-10 items-center justify-center rounded-xl border-2 border-surface-container text-on-surface-variant transition-colors hover:border-primary hover:text-primary">
|
||||
<span className="material-symbols-outlined text-xl">
|
||||
filter_list
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="px-6 py-6 text-sm font-bold text-error">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead className="bg-surface-container-low/60">
|
||||
<tr>
|
||||
<th className="w-4 px-6 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-surface-container text-primary focus:ring-primary"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{p.table.product}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{p.table.price}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{p.table.stock}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{p.table.market}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-[10px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{p.table.action}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-container">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-6 py-10 text-center text-sm font-bold text-on-surface-variant"
|
||||
>
|
||||
{p.loading}
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-6 py-10 text-center text-sm font-bold text-on-surface-variant"
|
||||
>
|
||||
{p.empty}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((product) => {
|
||||
const stockTone =
|
||||
product.totalStock === 0
|
||||
? "red"
|
||||
: product.totalStock <= 20
|
||||
? "amber"
|
||||
: "green";
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={product.id}
|
||||
className={`group transition-colors hover:bg-surface-container-low/60 ${
|
||||
stockTone === "red" ? "bg-error-container/10" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="w-4 px-6 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-surface-container text-primary focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative h-11 w-11 flex-shrink-0 overflow-hidden rounded-lg bg-surface-container">
|
||||
{product.image ? (
|
||||
<Image
|
||||
alt={product.name}
|
||||
src={product.image}
|
||||
fill
|
||||
sizes="44px"
|
||||
className={`object-cover ${
|
||||
stockTone === "red" ? "grayscale" : ""
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] font-black text-outline">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={`text-xs font-bold transition-colors group-hover:text-primary ${
|
||||
stockTone === "red"
|
||||
? "text-outline line-through"
|
||||
: "text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-outline">
|
||||
ID: {product.id.slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span
|
||||
className={`text-xs font-bold ${
|
||||
stockTone === "red"
|
||||
? "text-outline"
|
||||
: "text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{formatPrice(product)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div
|
||||
className={`flex items-center justify-center gap-1.5 ${
|
||||
stockTone === "red" ? "text-error" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
stockTone === "red"
|
||||
? "bg-error"
|
||||
: stockTone === "amber"
|
||||
? "bg-amber-500"
|
||||
: "bg-green-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
{product.totalStock === 0
|
||||
? "Out of Stock"
|
||||
: product.totalStock}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-tight ${marketClasses(
|
||||
product.market
|
||||
)}`}
|
||||
>
|
||||
{product.market}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
|
||||
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{p.edit}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : ""}`}
|
||||
className="text-[10px] font-bold text-primary transition-colors hover:underline"
|
||||
>
|
||||
{p.detail}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(product)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors"
|
||||
title="Hapus"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between bg-surface-container-low/50 px-6 py-3">
|
||||
<p className="text-[10px] font-medium text-on-surface-variant">
|
||||
{p.table.showing} {rows.length} {p.table.of} {totalItem || rows.length} {p.table.products}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="rounded-lg border border-surface-container p-1.5 text-on-surface-variant transition-colors hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xs">chevron_left</span>
|
||||
</button>
|
||||
<button className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-[10px] font-bold text-white">
|
||||
{page}
|
||||
</button>
|
||||
<span className="px-2 text-[10px] font-bold text-on-surface-variant">
|
||||
/ {Math.max(totalPage, 1)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(Math.max(totalPage, 1), p + 1))}
|
||||
disabled={page >= totalPage || loading}
|
||||
className="rounded-lg border border-surface-container p-1.5 text-on-surface-variant transition-colors hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xs">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
product={deleteTarget}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
deleting={deleting}
|
||||
d={p.deleteDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="px-8 pb-12 pt-24 text-sm font-semibold text-on-surface-variant">
|
||||
Loading products...
|
||||
</div>
|
||||
}>
|
||||
<ProductsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
295
src/app/(dashboard)/settings/change-password/page.tsx
Normal file
295
src/app/(dashboard)/settings/change-password/page.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function getPasswordStrength(password: string): number {
|
||||
if (!password) return 0;
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
|
||||
if (/\d/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
return Math.min(score, 4);
|
||||
}
|
||||
|
||||
function getStrengthColor(score: number): string {
|
||||
if (score === 1) return "#ba1a1a";
|
||||
if (score === 2) return "#b7131a";
|
||||
if (score === 3) return "#2d9648";
|
||||
return "#1b7a3c";
|
||||
}
|
||||
|
||||
// ─── Password Field ───────────────────────────────────────────────────────────
|
||||
|
||||
function PasswordField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[10px] font-black uppercase tracking-[0.18em] text-on-surface-variant">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
className="w-full bg-transparent border-b-2 border-outline-variant/40 focus:border-primary pb-2 pt-1 pr-10 text-sm font-semibold text-on-surface placeholder:text-on-surface-variant/30 focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
className="absolute right-0 top-0 text-on-surface-variant/60 hover:text-on-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
{show ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Strength Bar ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StrengthBar({
|
||||
password,
|
||||
prefix,
|
||||
labels,
|
||||
}: {
|
||||
password: string;
|
||||
prefix: string;
|
||||
labels: [string, string, string, string];
|
||||
}) {
|
||||
const score = getPasswordStrength(password);
|
||||
const color = getStrengthColor(score);
|
||||
const label = score > 0 ? labels[score - 1] : "";
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 mt-1">
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-1 flex-1 rounded-full transition-all duration-300"
|
||||
style={{ backgroundColor: i <= score ? color : "#e4beb9" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] font-black tracking-[0.15em]" style={{ color }}>
|
||||
{prefix} {label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const cp = t.dashboard.changePassword;
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const strengthLabels: [string, string, string, string] = [
|
||||
cp.strengthWeak,
|
||||
cp.strengthModerate,
|
||||
cp.strengthStrong,
|
||||
cp.strengthVeryStrong,
|
||||
];
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setError(cp.errorRequired);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(cp.errorMismatch);
|
||||
return;
|
||||
}
|
||||
if (getPasswordStrength(newPassword) < 2) {
|
||||
setError(cp.errorWeak);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/profile/change-password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify({ oldPassword: currentPassword, newPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.responseDesc || data?.message || cp.errorGeneric);
|
||||
}
|
||||
setSuccess(true);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : cp.errorGeneric);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 md:px-10 py-8 pb-10">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="font-headline font-black text-4xl text-on-surface tracking-tight mb-1">
|
||||
{cp.title}
|
||||
</h1>
|
||||
<nav className="flex items-center gap-2 text-[10px] uppercase tracking-widest font-bold text-outline">
|
||||
<span>{cp.settings}</span>
|
||||
<span>/</span>
|
||||
<span className="text-primary">{cp.security}</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="flex rounded-2xl overflow-hidden shadow-lg border border-outline-variant/10 max-w-4xl">
|
||||
{/* ── Left: Form ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 bg-surface-container-lowest p-8 md:p-10 space-y-6">
|
||||
{success && (
|
||||
<div className="rounded-xl border border-tertiary/20 bg-tertiary/5 px-4 py-3 text-sm font-semibold text-tertiary flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
check_circle
|
||||
</span>
|
||||
{cp.successMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-error text-[18px]">error</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-7">
|
||||
<PasswordField label={cp.currentPassword} value={currentPassword} onChange={setCurrentPassword} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<PasswordField label={cp.newPassword} value={newPassword} onChange={setNewPassword} />
|
||||
<StrengthBar password={newPassword} prefix={cp.strengthPrefix} labels={strengthLabels} />
|
||||
</div>
|
||||
|
||||
<PasswordField label={cp.confirmNewPassword} value={confirmPassword} onChange={setConfirmPassword} />
|
||||
|
||||
{confirmPassword && newPassword !== confirmPassword && (
|
||||
<p className="text-[11px] font-semibold text-error -mt-4">{cp.mismatch}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-5 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="editorial-gradient text-white px-8 py-3 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-md shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0 flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{cp.saving}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[18px]">lock_reset</span>
|
||||
{cp.updatePassword}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={saving}
|
||||
className="text-sm font-black uppercase tracking-[0.15em] text-on-surface-variant hover:text-primary transition-colors disabled:opacity-40"
|
||||
>
|
||||
{cp.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Security Guidelines ──────────────────────────── */}
|
||||
<div
|
||||
className="w-72 shrink-0 flex flex-col justify-between p-8"
|
||||
style={{ background: "linear-gradient(160deg, #1a1f2e 0%, #2e3132 100%)" }}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<h2 className="font-headline font-black text-base uppercase tracking-[0.2em] text-white whitespace-pre-line">
|
||||
{cp.guidelines.title}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-5">
|
||||
{[
|
||||
{ title: cp.guidelines.length, desc: cp.guidelines.lengthDesc },
|
||||
{ title: cp.guidelines.mix, desc: cp.guidelines.mixDesc },
|
||||
{ title: cp.guidelines.entropy, desc: cp.guidelines.entropyDesc },
|
||||
].map((item) => (
|
||||
<div key={item.title} className="flex gap-3">
|
||||
<div className="mt-0.5 w-5 h-5 rounded-full bg-primary/80 flex items-center justify-center shrink-0">
|
||||
<span
|
||||
className="material-symbols-outlined text-white text-[13px]"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
location_on
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-white mb-0.5">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-white/60 font-medium leading-relaxed">
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-6 border-t border-white/10 mt-6">
|
||||
<span className="material-symbols-outlined text-white/40 text-[16px]">lock</span>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.12em] text-white/40">
|
||||
{cp.guidelines.lastChanged}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/app/(dashboard)/settings/layout.tsx
Normal file
56
src/app/(dashboard)/settings/layout.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { t } = useLanguage();
|
||||
const s = t.dashboard.settings;
|
||||
|
||||
const settingsNav = [
|
||||
{ href: "/settings", icon: "account_circle", label: s.profile },
|
||||
{ href: "/settings/change-password", icon: "shield", label: s.changePassword },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Settings sub-sidebar */}
|
||||
<aside className="w-56 shrink-0 bg-surface-container-lowest border-r border-surface-container py-8 px-3 pt-12">
|
||||
<p className="text-[9px] font-black uppercase tracking-[0.2em] text-outline px-3 mb-3">
|
||||
{s.account}
|
||||
</p>
|
||||
<nav className="space-y-0.5">
|
||||
{settingsNav.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||
isActive
|
||||
? "bg-primary/8 text-primary border-l-4 border-primary rounded-l-none"
|
||||
: "text-on-surface-variant hover:bg-surface-container hover:translate-x-1 transition-transform"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px]"
|
||||
style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Page content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
571
src/app/(dashboard)/settings/page.tsx
Normal file
571
src/app/(dashboard)/settings/page.tsx
Normal file
@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
interface SellerProfile {
|
||||
biography: string;
|
||||
sellerId: string;
|
||||
sellerImageUrl: string | null;
|
||||
storeImageUrl: string | null;
|
||||
storeName: string;
|
||||
}
|
||||
|
||||
// ─── Avatar Upload ─────────────────────────────────────────────────────────────
|
||||
|
||||
function AvatarUpload({
|
||||
currentUrl,
|
||||
previewUrl,
|
||||
onUploaded,
|
||||
editMode,
|
||||
}: {
|
||||
currentUrl: string | null;
|
||||
previewUrl: string;
|
||||
onUploaded: (fileId: string, objectUrl: string) => void;
|
||||
editMode: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onUploaded(id, objectUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload gagal");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const displayUrl = previewUrl || currentUrl;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="w-48 h-48 rounded-full border-4 border-primary/10 mb-6 overflow-hidden bg-surface-container-low flex items-center justify-center">
|
||||
{displayUrl ? (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="Seller Profile"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-outlined text-6xl text-outline/30"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
person
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{editMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="absolute bottom-6 right-4 bg-primary text-white p-3 rounded-full shadow-xl hover:scale-110 transition-transform disabled:opacity-60"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-lg">photo_camera</span>
|
||||
)}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-[10px] text-error text-center mt-1">{error}</p>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Store Photo Upload ────────────────────────────────────────────────────────
|
||||
|
||||
function StorePhotoUpload({
|
||||
currentUrl,
|
||||
previewUrl,
|
||||
onUploaded,
|
||||
onRemove,
|
||||
editMode,
|
||||
}: {
|
||||
currentUrl: string | null;
|
||||
previewUrl: string;
|
||||
onUploaded: (fileId: string, objectUrl: string) => void;
|
||||
onRemove: () => void;
|
||||
editMode: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
|
||||
if (!id) throw new Error("File id tidak ditemukan");
|
||||
onUploaded(id, objectUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload gagal");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const displayUrl = previewUrl || currentUrl;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Preview */}
|
||||
<div className="relative aspect-video bg-surface-container-lowest overflow-hidden group rounded-xl">
|
||||
{displayUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="Store Photo"
|
||||
className="w-full h-full object-cover grayscale hover:grayscale-0 transition-all duration-500"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{editMode && (
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="bg-primary/80 backdrop-blur-md p-3 rounded-xl text-white hover:bg-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-outline/30">
|
||||
<span className="material-symbols-outlined text-5xl mb-2">image</span>
|
||||
<p className="text-xs font-semibold">No store photo</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
{editMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="border-2 border-dashed border-outline-variant bg-surface-container-lowest/50 flex flex-col items-center justify-center p-8 text-center hover:bg-white transition-all rounded-xl disabled:opacity-60"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin mb-4" />
|
||||
<p className="text-xs font-bold text-outline uppercase tracking-wider">Uploading...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-4xl text-outline/30 mb-4">cloud_upload</span>
|
||||
<p className="text-xs font-bold text-on-surface uppercase tracking-wider mb-2">Upload New Asset</p>
|
||||
<p className="text-[10px] text-outline px-4">JPG, PNG or WEBP. Max 5MB. Recommended 1600×900px.</p>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="text-[10px] text-error mt-2">{error}</p>}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="border-2 border-dashed border-outline-variant/30 rounded-xl flex flex-col items-center justify-center p-8 text-center bg-surface-container-lowest/30">
|
||||
<span className="material-symbols-outlined text-3xl text-outline/20 mb-2">cloud_upload</span>
|
||||
<p className="text-[10px] text-outline/40 uppercase tracking-wider font-bold">Enable edit to upload</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useLanguage();
|
||||
const s = t.dashboard.settings;
|
||||
|
||||
const [profile, setProfile] = useState<SellerProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [storeName, setStoreName] = useState("");
|
||||
const [biography, setBiography] = useState("");
|
||||
const [sellerImageId, setSellerImageId] = useState("");
|
||||
const [sellerImagePreview, setSellerImagePreview] = useState("");
|
||||
const [storeImageId, setStoreImageId] = useState("");
|
||||
const [storeImagePreview, setStoreImagePreview] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/seller/profile", {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
const d: SellerProfile = j?.data || j;
|
||||
setProfile(d);
|
||||
setStoreName(d.storeName || "");
|
||||
setBiography(d.biography || "");
|
||||
})
|
||||
.catch(() => setError("Gagal memuat profil"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function handleEdit() {
|
||||
setEditMode(true);
|
||||
setSaveError("");
|
||||
setSaveSuccess(false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!profile) return;
|
||||
setStoreName(profile.storeName || "");
|
||||
setBiography(profile.biography || "");
|
||||
setSellerImageId("");
|
||||
setSellerImagePreview("");
|
||||
setStoreImageId("");
|
||||
setStoreImagePreview("");
|
||||
setEditMode(false);
|
||||
setSaveError("");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
setSaveSuccess(false);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
storeName,
|
||||
storeBiography: biography,
|
||||
};
|
||||
if (sellerImageId) body.imageId = sellerImageId;
|
||||
if (storeImageId) body.storeImageId = storeImageId;
|
||||
|
||||
const res = await fetch("/api/seller/profile", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result?.responseDesc || "Gagal menyimpan profil");
|
||||
|
||||
// Update local profile state
|
||||
setProfile((prev) => prev ? {
|
||||
...prev,
|
||||
storeName,
|
||||
biography,
|
||||
sellerImageUrl: sellerImagePreview || prev.sellerImageUrl,
|
||||
storeImageUrl: storeImagePreview || prev.storeImageUrl,
|
||||
} : prev);
|
||||
|
||||
setSaveSuccess(true);
|
||||
setEditMode(false);
|
||||
setSellerImageId("");
|
||||
setSellerImagePreview("");
|
||||
setStoreImageId("");
|
||||
setStoreImagePreview("");
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : s.errorSave);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full px-6 md:px-10 py-8 flex items-center justify-center min-h-[60vh]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm font-semibold text-on-surface-variant">{s.loading}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<div className="w-full px-6 md:px-10 py-8">
|
||||
<div className="rounded-xl border border-error/20 bg-error-container p-6 text-sm font-semibold text-on-error-container">
|
||||
{error || s.profileNotFound}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 md:px-10 py-8 pb-10 space-y-10">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="font-headline font-black text-4xl md:text-5xl text-on-surface tracking-tight mb-2">
|
||||
{s.sellerProfile}
|
||||
</h1>
|
||||
<nav className="flex items-center gap-2 text-[10px] uppercase tracking-widest font-bold text-outline">
|
||||
<span>{s.management}</span>
|
||||
<span>/</span>
|
||||
<span className="text-primary">{s.profileConfiguration}</span>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 text-sm font-black text-on-surface-variant border-2 border-outline-variant/30 rounded-xl hover:bg-surface-container-low transition-all disabled:opacity-40"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-8 py-3 editorial-gradient text-white font-bold rounded-xl shadow-lg shadow-primary/20 hover:-translate-y-0.5 active:translate-y-0 transition-all disabled:opacity-60 flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{s.saving}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[18px]">save</span>
|
||||
{s.saveChanges}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="px-8 py-3 editorial-gradient text-white font-bold rounded-xl shadow-lg shadow-primary/20 hover:-translate-y-0.5 active:translate-y-0 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
{s.editProfile}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{saveSuccess && (
|
||||
<div className="rounded-xl border border-tertiary/20 bg-tertiary/5 px-5 py-4 text-sm font-semibold text-tertiary flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[20px]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
{s.successUpdate}
|
||||
</div>
|
||||
)}
|
||||
{saveError && (
|
||||
<div className="rounded-xl border border-error/20 bg-error-container px-5 py-4 text-sm font-semibold text-on-error-container flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-error text-[20px]">error</span>
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bento Grid */}
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
|
||||
{/* ── Left: Profile Identity ─────────────────────────────────────── */}
|
||||
<section className="col-span-12 lg:col-span-4 bg-surface-container-low p-8 rounded-2xl border border-outline-variant/10 shadow-sm flex flex-col items-center text-center">
|
||||
<AvatarUpload
|
||||
currentUrl={profile.sellerImageUrl}
|
||||
previewUrl={sellerImagePreview}
|
||||
onUploaded={(id, url) => {
|
||||
setSellerImageId(id);
|
||||
setSellerImagePreview(url);
|
||||
}}
|
||||
editMode={editMode}
|
||||
/>
|
||||
<h3 className="font-headline font-extrabold text-2xl text-on-surface mb-1">
|
||||
{storeName || profile.storeName || "—"}
|
||||
</h3>
|
||||
<p className="text-sm text-on-surface-variant font-medium mt-2 leading-relaxed max-w-xs">
|
||||
{biography || profile.biography || ""}
|
||||
</p>
|
||||
|
||||
{/* Seller ID badge */}
|
||||
<div className="mt-6 w-full p-4 rounded-xl bg-surface-container border border-outline-variant/10">
|
||||
<p className="text-[9px] font-black uppercase tracking-[0.2em] text-outline mb-1">{s.sellerId}</p>
|
||||
<p className="text-xs font-mono font-bold text-on-surface break-all">{profile.sellerId}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Right: Forms ───────────────────────────────────────────────── */}
|
||||
<section className="col-span-12 lg:col-span-8 space-y-6">
|
||||
|
||||
{/* Store Information */}
|
||||
<div className="bg-surface-container-low p-8 md:p-10 rounded-2xl border border-outline-variant/10 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-primary/5 rounded-bl-full pointer-events-none" />
|
||||
<div className="relative z-10 space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-primary text-white flex items-center justify-center rounded-xl shadow-md shadow-primary/20">
|
||||
<span className="material-symbols-outlined">edit_note</span>
|
||||
</div>
|
||||
<h2 className="font-headline font-extrabold text-2xl tracking-tight text-on-surface">
|
||||
{s.storeInfo}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-outline uppercase tracking-[0.2em] mb-3">
|
||||
{s.storeName}
|
||||
</label>
|
||||
{editMode ? (
|
||||
<input
|
||||
value={storeName}
|
||||
onChange={(e) => setStoreName(e.target.value)}
|
||||
placeholder="Enter formal store name"
|
||||
className="w-full bg-surface-container-lowest border-none rounded-xl px-6 py-4 text-on-surface font-bold text-lg shadow-sm focus:ring-2 focus:ring-primary/20 focus:outline-none transition-all"
|
||||
/>
|
||||
) : (
|
||||
<p className="w-full bg-surface-container-lowest rounded-xl px-6 py-4 text-on-surface font-bold text-lg shadow-sm">
|
||||
{profile.storeName || <span className="text-outline font-medium">—</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-outline uppercase tracking-[0.2em] mb-3">
|
||||
{s.storeBiography}
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={biography}
|
||||
onChange={(e) => setBiography(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Describe your store's mission and value proposition"
|
||||
className="w-full bg-surface-container-lowest border-none rounded-xl px-6 py-4 text-on-surface font-medium text-sm leading-relaxed shadow-sm focus:ring-2 focus:ring-primary/20 focus:outline-none resize-none transition-all"
|
||||
/>
|
||||
) : (
|
||||
<p className="w-full bg-surface-container-lowest rounded-xl px-6 py-4 text-on-surface font-medium text-sm leading-relaxed shadow-sm min-h-[7rem] whitespace-pre-line">
|
||||
{profile.biography || <span className="text-outline">—</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store Photo */}
|
||||
<div className="bg-surface-container-low p-8 md:p-10 rounded-2xl border border-outline-variant/10 shadow-sm">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-10 h-10 bg-primary text-white flex items-center justify-center rounded-xl shadow-md shadow-primary/20">
|
||||
<span className="material-symbols-outlined">add_a_photo</span>
|
||||
</div>
|
||||
<h2 className="font-headline font-extrabold text-2xl tracking-tight text-on-surface">
|
||||
{s.storePhoto}
|
||||
</h2>
|
||||
</div>
|
||||
<StorePhotoUpload
|
||||
currentUrl={profile.storeImageUrl}
|
||||
previewUrl={storeImagePreview}
|
||||
onUploaded={(id, url) => {
|
||||
setStoreImageId(id);
|
||||
setStoreImagePreview(url);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setStoreImageId("");
|
||||
setStoreImagePreview("");
|
||||
setProfile((prev) => prev ? { ...prev, storeImageUrl: null } : prev);
|
||||
}}
|
||||
editMode={editMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Footer */}
|
||||
<div className="flex items-center justify-between px-8 py-6 bg-surface-container-highest/20 border-l-4 border-primary rounded-r-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="material-symbols-outlined text-primary text-[22px] shrink-0"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<p className="text-[11px] font-medium text-on-surface-variant max-w-sm leading-relaxed">
|
||||
{s.complianceNote}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-primary font-black uppercase text-[10px] tracking-[0.2em] hover:translate-x-1 transition-transform flex-shrink-0 ml-4"
|
||||
>
|
||||
{s.viewStorefront}
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/app/(onboarding)/layout.tsx
Normal file
164
src/app/(onboarding)/layout.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LanguageToggle } from "@/components/language-toggle";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const steps = [
|
||||
{ href: "/onboarding/business", icon: "storefront", labelKey: "business" as const, step: 1 },
|
||||
{ href: "/onboarding/store-detail", icon: "store", labelKey: "storeDetail" as const, step: 2 },
|
||||
{ href: "/onboarding/plan", icon: "payments", labelKey: "plan" as const, step: 3 },
|
||||
];
|
||||
|
||||
export default function OnboardingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { t } = useLanguage();
|
||||
const lo = t.onboarding.layout;
|
||||
|
||||
const isSuccessPage = pathname.startsWith("/onboarding/success");
|
||||
const currentStep = steps.find((s) => pathname.startsWith(s.href));
|
||||
const stepNumber = currentStep?.step ?? (isSuccessPage ? steps.length : 1);
|
||||
|
||||
if (isSuccessPage) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-on-surface font-body antialiased">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-on-surface font-body antialiased">
|
||||
{/* Top Header */}
|
||||
<header className="fixed top-0 w-full z-50 bg-white/85 backdrop-blur-md shadow-sm flex justify-between items-center px-6 py-4">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={140} height={42} priority />
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-surface-container-high rounded-full">
|
||||
<span className="text-xs font-bold text-primary">
|
||||
Step {stepNumber} {lo.stepOf} {steps.length}
|
||||
</span>
|
||||
<div className="w-24 h-1.5 bg-outline-variant/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${(stepNumber / steps.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LanguageToggle />
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-screen pt-20">
|
||||
{/* Sidebar */}
|
||||
<aside className="sticky top-20 hidden h-[calc(100vh-5rem)] w-64 flex-col gap-6 overflow-y-auto bg-surface-container-low py-8 md:flex">
|
||||
<div className="px-8 mb-4">
|
||||
<h2 className="text-xl font-black text-primary font-headline">{lo.setupGuide}</h2>
|
||||
<p className="text-xs text-on-surface-variant font-medium">{lo.progress}</p>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{steps.map((s) => {
|
||||
const isActive = pathname.startsWith(s.href);
|
||||
const isDone = s.step < stepNumber;
|
||||
const isLocked = s.step > stepNumber;
|
||||
const label = lo[s.labelKey];
|
||||
const className = `flex items-center gap-3 py-3 pl-4 transition-all duration-300 font-semibold ${
|
||||
isActive
|
||||
? "text-primary font-bold border-l-4 border-primary"
|
||||
: isLocked
|
||||
? "cursor-not-allowed text-outline/70"
|
||||
: "text-on-surface-variant hover:bg-primary/5"
|
||||
}`;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{
|
||||
fontVariationSettings: isDone ? "'FILL' 1" : "'FILL' 0",
|
||||
}}
|
||||
>
|
||||
{isDone ? "check_circle" : s.icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
return (
|
||||
<span key={s.href} className={className} aria-disabled="true">
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={s.href} href={s.href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="mt-auto px-6 pb-2">
|
||||
<div className="rounded-xl bg-surface-container-highest p-4 shadow-sm">
|
||||
<p className="text-[10px] uppercase tracking-widest font-bold text-outline mb-2">{lo.proTip}</p>
|
||||
<p className="text-xs leading-relaxed text-on-surface-variant">
|
||||
{lo.proTipText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-y-auto pb-24 md:pb-0">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Bottom Nav Mobile */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white/95 backdrop-blur-md flex justify-around items-center py-4 px-2 z-50">
|
||||
{steps.map((s) => {
|
||||
const isActive = pathname.startsWith(s.href);
|
||||
const isLocked = s.step > stepNumber;
|
||||
const className = `flex flex-col items-center gap-1 ${
|
||||
isActive
|
||||
? "text-primary"
|
||||
: isLocked
|
||||
? "cursor-not-allowed text-outline/70"
|
||||
: "text-on-surface-variant"
|
||||
}`;
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontVariationSettings: isActive ? "'FILL' 1" : "'FILL' 0" }}
|
||||
>
|
||||
{s.icon}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold">{lo[s.labelKey]}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
return (
|
||||
<span key={s.href} className={className} aria-disabled="true">
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={s.href} href={s.href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1152
src/app/(onboarding)/onboarding/business/page.tsx
Normal file
1152
src/app/(onboarding)/onboarding/business/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
371
src/app/(onboarding)/onboarding/plan/page.tsx
Normal file
371
src/app/(onboarding)/onboarding/plan/page.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
type PlanId = "starter" | "professional" | "organization" | "enterprise";
|
||||
|
||||
interface PlanOption {
|
||||
id: PlanId;
|
||||
borderClass: string;
|
||||
actionClass: string;
|
||||
badge?: boolean;
|
||||
featureKeys: string[];
|
||||
price: string;
|
||||
unit: string;
|
||||
seats: string;
|
||||
ctaKey: "currentStep" | "selectPlan" | "contactUs";
|
||||
}
|
||||
|
||||
const PLANS: PlanOption[] = [
|
||||
{
|
||||
id: "starter",
|
||||
price: "Free",
|
||||
unit: "/mo",
|
||||
seats: "1 Seat",
|
||||
borderClass: "border-slate-200",
|
||||
actionClass: "bg-surface-container-high text-on-surface hover:bg-surface-dim",
|
||||
ctaKey: "currentStep",
|
||||
featureKeys: ["basicAnalytics", "upTo10Trades", "apiAccess"],
|
||||
},
|
||||
{
|
||||
id: "professional",
|
||||
price: "$29",
|
||||
unit: "/mo",
|
||||
seats: "5 Seats",
|
||||
borderClass: "border-primary ring-2 ring-primary/20 shadow-2xl shadow-primary/10",
|
||||
actionClass: "bg-gradient-to-br from-primary to-primary-container text-on-primary shadow-md shadow-primary/20 hover:opacity-95",
|
||||
badge: true,
|
||||
ctaKey: "selectPlan",
|
||||
featureKeys: ["advancedReports", "unlimitedTrades", "exportToExcel"],
|
||||
},
|
||||
{
|
||||
id: "organization",
|
||||
price: "$99",
|
||||
unit: "/mo",
|
||||
seats: "20 Seats",
|
||||
borderClass: "border-tertiary/40",
|
||||
actionClass: "bg-secondary text-on-secondary hover:opacity-90",
|
||||
ctaKey: "selectPlan",
|
||||
featureKeys: ["fullApiAccess", "customDashboards", "teamManagement"],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
price: "Talk",
|
||||
unit: "/sales",
|
||||
seats: "Unlimited",
|
||||
borderClass: "border-inverse-surface/30",
|
||||
actionClass: "border-2 border-on-surface bg-transparent text-on-surface hover:bg-on-surface hover:text-white",
|
||||
ctaKey: "contactUs",
|
||||
featureKeys: ["slaGuarantee", "dedicatedManager", "customIntegrations"],
|
||||
},
|
||||
];
|
||||
|
||||
const PLAN_NAMES: Record<PlanId, string> = {
|
||||
starter: "Starter",
|
||||
professional: "Professional",
|
||||
organization: "Organization",
|
||||
enterprise: "Enterprise",
|
||||
};
|
||||
|
||||
const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft";
|
||||
const ONBOARDING_STORE_STORAGE_KEY = "onboardingStoreDetailDraft";
|
||||
|
||||
// Which features are included per plan (index 0,1,2 correspond to featureKeys)
|
||||
const PLAN_INCLUDED: Record<PlanId, [boolean, boolean, boolean]> = {
|
||||
starter: [true, true, false],
|
||||
professional: [true, true, true],
|
||||
organization: [true, true, true],
|
||||
enterprise: [true, true, true],
|
||||
};
|
||||
|
||||
function featureIconClass(planId: PlanId) {
|
||||
if (planId === "organization") return "text-tertiary";
|
||||
if (planId === "enterprise") return "text-on-surface";
|
||||
return "text-primary";
|
||||
}
|
||||
|
||||
export default function PlanPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const p = t.onboarding.plan;
|
||||
const common = t.common;
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanId>(() => {
|
||||
if (typeof window === "undefined") return "professional";
|
||||
const storedPlan = sessionStorage.getItem("selectedPlan") as PlanId | null;
|
||||
return storedPlan && PLANS.some((plan) => plan.id === storedPlan)
|
||||
? storedPlan
|
||||
: "professional";
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function getToken() {
|
||||
return (
|
||||
sessionStorage.getItem("token") || localStorage.getItem("token") || ""
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
const businessDraft = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
const storeDraft = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!businessDraft) {
|
||||
router.replace("/onboarding/business");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storeDraft) {
|
||||
router.replace("/onboarding/store-detail");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
function persistPlan(planId: PlanId) {
|
||||
setSelectedPlan(planId);
|
||||
sessionStorage.setItem("selectedPlan", planId);
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
sessionStorage.setItem("selectedPlan", selectedPlan);
|
||||
|
||||
const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
if (!businessDraftRaw) {
|
||||
throw new Error("Business onboarding data not found");
|
||||
}
|
||||
|
||||
const storeDraftRaw = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
if (!storeDraftRaw) {
|
||||
throw new Error("Store detail onboarding data not found");
|
||||
}
|
||||
|
||||
const businessDraft = JSON.parse(businessDraftRaw) as {
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const storeDraft = JSON.parse(storeDraftRaw) as {
|
||||
payload?: {
|
||||
store: Record<string, unknown>;
|
||||
warehouse: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
if (!businessDraft.payload) {
|
||||
throw new Error("Business onboarding payload not found");
|
||||
}
|
||||
|
||||
if (!storeDraft.payload?.store || !storeDraft.payload?.warehouse) {
|
||||
throw new Error("Store detail onboarding payload not found");
|
||||
}
|
||||
|
||||
const sellerRes = await fetch("/api/seller", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(businessDraft.payload),
|
||||
});
|
||||
|
||||
const sellerData = await sellerRes.json().catch(() => ({}));
|
||||
if (!sellerRes.ok) {
|
||||
throw new Error(
|
||||
sellerData?.error || sellerData?.responseDesc || common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
const storeRes = await fetch("/api/seller/store", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(storeDraft.payload.store),
|
||||
});
|
||||
|
||||
const storeData = await storeRes.json().catch(() => ({}));
|
||||
if (!storeRes.ok) {
|
||||
throw new Error(
|
||||
storeData?.error || storeData?.responseDesc || common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
const warehouseRes = await fetch("/api/warehouses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": getToken(),
|
||||
},
|
||||
body: JSON.stringify(storeDraft.payload.warehouse),
|
||||
});
|
||||
|
||||
const warehouseData = await warehouseRes.json().catch(() => ({}));
|
||||
if (!warehouseRes.ok) {
|
||||
throw new Error(
|
||||
warehouseData?.error ||
|
||||
warehouseData?.responseDesc ||
|
||||
common.connectionError
|
||||
);
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
router.push("/onboarding/success");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : common.connectionError
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-surface px-6 pb-36 pt-24 text-on-surface">
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-8">
|
||||
<div className="flex-1">
|
||||
<header className="mb-12">
|
||||
{error ? (
|
||||
<div className="mb-6 rounded-xl bg-error-container px-4 py-3 text-sm font-medium text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary">
|
||||
{p.step}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mb-4 max-w-4xl font-headline text-5xl font-extrabold leading-tight tracking-tight text-on-surface">
|
||||
{p.title}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-xl leading-relaxed text-on-surface-variant">
|
||||
{p.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{PLANS.map((plan) => {
|
||||
const isSelected = selectedPlan === plan.id;
|
||||
const included = PLAN_INCLUDED[plan.id];
|
||||
|
||||
return (
|
||||
<article
|
||||
key={plan.id}
|
||||
className={`relative flex min-h-[430px] flex-col rounded-xl border-l-4 bg-surface-container-lowest p-8 transition-all duration-200 hover:-translate-y-1 ${plan.borderClass} ${
|
||||
isSelected ? "scale-[1.01]" : ""
|
||||
}`}
|
||||
>
|
||||
{plan.badge ? (
|
||||
<div className="absolute -top-4 left-6 rounded-full bg-primary px-3 py-1 text-[10px] font-black uppercase tracking-tight text-white shadow-lg">
|
||||
{p.mostPopular}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h3
|
||||
className={`mb-1 text-lg font-bold ${
|
||||
plan.id === "professional"
|
||||
? "text-primary"
|
||||
: plan.id === "organization"
|
||||
? "text-tertiary"
|
||||
: "text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{PLAN_NAMES[plan.id]}
|
||||
</h3>
|
||||
|
||||
<div className="mb-6 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-black text-on-surface">{plan.price}</span>
|
||||
<span className="text-sm text-on-surface-variant">{plan.unit}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 space-y-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-on-surface">
|
||||
<span className="material-symbols-outlined text-sm">group</span>
|
||||
{plan.seats}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{plan.featureKeys.map((key, idx) => {
|
||||
const featureLabel = p.features[key as keyof typeof p.features];
|
||||
const isIncluded = included[idx];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex gap-2 text-sm ${
|
||||
isIncluded ? "text-on-surface-variant" : "text-on-surface-variant opacity-40"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-sm ${
|
||||
isIncluded ? featureIconClass(plan.id) : "text-on-surface-variant"
|
||||
}`}
|
||||
>
|
||||
{isIncluded ? "check" : "close"}
|
||||
</span>
|
||||
<span>{featureLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => persistPlan(plan.id)}
|
||||
className={`mt-auto w-full rounded-xl px-4 py-4 font-bold transition-all active:scale-95 ${plan.actionClass}`}
|
||||
>
|
||||
{isSelected ? p.selected : p[plan.ctaKey]}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="fixed bottom-0 left-0 right-0 z-40 bg-surface-container-lowest px-6 py-6 shadow-[0_-10px_30px_rgba(25,28,29,0.04)]">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/onboarding/store-detail")}
|
||||
className="flex items-center gap-2 rounded-xl px-6 py-2 font-bold text-on-surface transition-colors hover:bg-surface-container"
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
{p.back}
|
||||
</button>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
disabled={submitting}
|
||||
className="px-4 font-medium text-on-surface-variant disabled:opacity-70"
|
||||
>
|
||||
{p.skip}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-xl bg-primary px-10 py-4 font-bold text-on-primary shadow-xl shadow-primary/20 transition-all hover:scale-105 active:scale-95 disabled:opacity-70 disabled:hover:scale-100"
|
||||
>
|
||||
{submitting ? `${p.submitSelection}...` : p.submitSelection}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
src/app/(onboarding)/onboarding/security/page.tsx
Normal file
55
src/app/(onboarding)/onboarding/security/page.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SecurityPage() {
|
||||
const router = useRouter();
|
||||
const selectedPlan =
|
||||
typeof window !== "undefined" ? sessionStorage.getItem("selectedPlan") : "";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface px-6 pb-36 pt-24 text-on-surface">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-8 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary">
|
||||
<span>Step 03 of 03</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-outline-variant/30 bg-surface-container-lowest p-10 shadow-sm">
|
||||
<h1 className="mb-4 font-headline text-5xl font-extrabold tracking-tight">
|
||||
Security setup
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg leading-relaxed text-on-surface-variant">
|
||||
Step ini masih placeholder. Plan yang dipilih sudah disimpan lokal
|
||||
dan siap dipakai saat integrasi berikutnya.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 rounded-2xl bg-surface-container-low p-6">
|
||||
<p className="text-[11px] font-black uppercase tracking-widest text-outline">
|
||||
Selected Plan
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-bold capitalize text-on-surface">
|
||||
{selectedPlan || "Belum dipilih"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/onboarding/plan")}
|
||||
className="rounded-xl border border-outline-variant/60 px-6 py-3 font-bold text-on-surface transition-colors hover:bg-surface-container-low"
|
||||
>
|
||||
Back to Plan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/onboarding/success")}
|
||||
className="rounded-xl bg-primary px-8 py-3 font-bold text-on-primary shadow-lg shadow-primary/20 transition-all hover:opacity-95"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
446
src/app/(onboarding)/onboarding/store-detail/page.tsx
Normal file
446
src/app/(onboarding)/onboarding/store-detail/page.tsx
Normal file
@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UploadField } from "@/components/upload-field";
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft";
|
||||
const ONBOARDING_STORE_STORAGE_KEY = "onboardingStoreDetailDraft";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-3.5 text-sm text-on-surface shadow-sm transition-all focus:border-secondary focus:bg-surface-container-lowest focus:outline-none";
|
||||
|
||||
const headlineFieldClass =
|
||||
"w-full rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-3.5 font-headline text-lg font-bold text-on-surface shadow-sm transition-all focus:border-primary focus:bg-surface-container-lowest focus:outline-none";
|
||||
|
||||
const textareaClass =
|
||||
"w-full resize-none rounded-xl border border-outline-variant/60 bg-surface-container-low px-4 py-3.5 text-sm leading-relaxed text-on-surface shadow-sm transition-all focus:border-primary focus:bg-surface-container-lowest focus:outline-none";
|
||||
|
||||
type WarehouseType = "INA" | "OTHER";
|
||||
|
||||
function toNumber(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return 0;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const router = useRouter();
|
||||
const { t } = useLanguage();
|
||||
const sd = t.onboarding.storeDetail;
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [store, setStore] = useState({
|
||||
storeName: "",
|
||||
storeBiography: "",
|
||||
imageId: "",
|
||||
storeImageId: "",
|
||||
});
|
||||
const [warehouse, setWarehouse] = useState({
|
||||
name: "",
|
||||
address: "",
|
||||
warehouseType: "INA" as WarehouseType,
|
||||
country: "Indonesia",
|
||||
province: "",
|
||||
city: "",
|
||||
postalCode: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
});
|
||||
|
||||
function getToken() {
|
||||
return (
|
||||
sessionStorage.getItem("token") || localStorage.getItem("token") || ""
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
|
||||
if (!token) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!businessDraftRaw) {
|
||||
router.replace("/onboarding/business");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const businessDraft = JSON.parse(businessDraftRaw) as {
|
||||
payload?: {
|
||||
country?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
};
|
||||
};
|
||||
|
||||
setWarehouse((prev) => ({
|
||||
...prev,
|
||||
country: businessDraft.payload?.country || prev.country,
|
||||
province: businessDraft.payload?.province || prev.province,
|
||||
city: businessDraft.payload?.city || prev.city,
|
||||
postalCode: businessDraft.payload?.postalCode || prev.postalCode,
|
||||
}));
|
||||
} catch {
|
||||
sessionStorage.removeItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||
router.replace("/onboarding/business");
|
||||
return;
|
||||
}
|
||||
|
||||
const storeDraftRaw = sessionStorage.getItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
if (!storeDraftRaw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(storeDraftRaw) as {
|
||||
store?: typeof store;
|
||||
warehouse?: typeof warehouse;
|
||||
};
|
||||
|
||||
if (parsed.store) {
|
||||
setStore(parsed.store);
|
||||
}
|
||||
|
||||
if (parsed.warehouse) {
|
||||
setWarehouse(parsed.warehouse);
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(ONBOARDING_STORE_STORAGE_KEY);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (
|
||||
!store.storeName.trim() ||
|
||||
!store.storeBiography.trim() ||
|
||||
!warehouse.name.trim() ||
|
||||
!warehouse.address.trim() ||
|
||||
!warehouse.country.trim() ||
|
||||
!warehouse.province.trim() ||
|
||||
!warehouse.city.trim() ||
|
||||
!warehouse.postalCode.trim() ||
|
||||
!warehouse.latitude.trim() ||
|
||||
!warehouse.longitude.trim()
|
||||
) {
|
||||
throw new Error(sd.requiredError);
|
||||
}
|
||||
|
||||
const resolvedStoreImageId = store.storeImageId || store.imageId;
|
||||
|
||||
const payload = {
|
||||
store: {
|
||||
imageId: resolvedStoreImageId,
|
||||
storeName: store.storeName.trim(),
|
||||
storeImageId: resolvedStoreImageId,
|
||||
storeBiography: store.storeBiography.trim(),
|
||||
},
|
||||
warehouse: {
|
||||
name: warehouse.name.trim(),
|
||||
address: warehouse.address.trim(),
|
||||
country: warehouse.country.trim(),
|
||||
province: warehouse.province.trim(),
|
||||
city: warehouse.city.trim(),
|
||||
postalCode: warehouse.postalCode.trim(),
|
||||
latitude: toNumber(warehouse.latitude),
|
||||
longitude: toNumber(warehouse.longitude),
|
||||
warehouseType: warehouse.warehouseType,
|
||||
},
|
||||
};
|
||||
|
||||
sessionStorage.setItem(
|
||||
ONBOARDING_STORE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
store,
|
||||
warehouse,
|
||||
payload,
|
||||
})
|
||||
);
|
||||
|
||||
router.push("/onboarding/plan");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : sd.genericError);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-container-low px-6 py-10 md:px-10 lg:px-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<header className="mb-12">
|
||||
<h1 className="mb-3 font-headline text-5xl font-black tracking-tighter text-on-surface">
|
||||
{sd.title}
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg font-medium text-on-surface-variant">
|
||||
{sd.subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error ? (
|
||||
<div className="mb-8 rounded-xl bg-error-container px-4 py-3 text-sm font-medium text-on-error-container">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
|
||||
<section className="lg:col-span-5">
|
||||
<div className="relative h-full bg-surface-container-lowest p-8 shadow-sm">
|
||||
<div className="absolute left-0 top-0 h-full w-1 bg-primary" />
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-primary">storefront</span>
|
||||
<h2 className="text-xl font-bold uppercase tracking-tight text-on-surface">
|
||||
{sd.storeInfo}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-7">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.storeName}
|
||||
</label>
|
||||
<input
|
||||
value={store.storeName}
|
||||
onChange={(e) =>
|
||||
setStore((prev) => ({ ...prev, storeName: e.target.value }))
|
||||
}
|
||||
placeholder={sd.storeNamePlaceholder}
|
||||
className={headlineFieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.storeBiography}
|
||||
</label>
|
||||
<textarea
|
||||
value={store.storeBiography}
|
||||
onChange={(e) =>
|
||||
setStore((prev) => ({
|
||||
...prev,
|
||||
storeBiography: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={sd.storeBiographyPlaceholder}
|
||||
rows={5}
|
||||
className={textareaClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UploadField
|
||||
label={sd.storePhoto}
|
||||
value={store.storeImageId || store.imageId}
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
helperText={sd.storePhotoHelper}
|
||||
onUploaded={(fileId) =>
|
||||
setStore((prev) => ({
|
||||
...prev,
|
||||
imageId: fileId,
|
||||
storeImageId: fileId,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="lg:col-span-7">
|
||||
<div className="relative h-full bg-surface-container-lowest p-8 shadow-sm">
|
||||
<div className="absolute left-0 top-0 h-full w-1 bg-secondary" />
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-secondary">warehouse</span>
|
||||
<h2 className="text-xl font-bold uppercase tracking-tight text-on-surface">
|
||||
{sd.warehouseSetup}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-secondary/10 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-secondary">
|
||||
{sd.primaryHub}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.warehouseName}
|
||||
</label>
|
||||
<input
|
||||
value={warehouse.name}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder={sd.warehouseNamePlaceholder}
|
||||
className={headlineFieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.fullAddress}
|
||||
</label>
|
||||
<input
|
||||
value={warehouse.address}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({ ...prev, address: e.target.value }))
|
||||
}
|
||||
placeholder={sd.fullAddressPlaceholder}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.warehouseType}
|
||||
</label>
|
||||
<select
|
||||
value={warehouse.warehouseType}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({
|
||||
...prev,
|
||||
warehouseType: e.target.value as WarehouseType,
|
||||
}))
|
||||
}
|
||||
className={fieldClass}
|
||||
>
|
||||
<option value="INA">INA</option>
|
||||
<option value="OTHER">{sd.other}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.country}
|
||||
</label>
|
||||
<input
|
||||
value={warehouse.country}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({ ...prev, country: e.target.value }))
|
||||
}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.province}
|
||||
</label>
|
||||
<input
|
||||
value={warehouse.province}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({ ...prev, province: e.target.value }))
|
||||
}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.city}
|
||||
</label>
|
||||
<input
|
||||
value={warehouse.city}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({ ...prev, city: e.target.value }))
|
||||
}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.postalCode}
|
||||
</label>
|
||||
<input
|
||||
value={warehouse.postalCode}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({
|
||||
...prev,
|
||||
postalCode: e.target.value,
|
||||
}))
|
||||
}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.latitude}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={warehouse.latitude}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({
|
||||
...prev,
|
||||
latitude: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="5.548290"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
{sd.longitude}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={warehouse.longitude}
|
||||
onChange={(e) =>
|
||||
setWarehouse((prev) => ({
|
||||
...prev,
|
||||
longitude: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="95.323753"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col gap-6 border-t border-outline-variant/40 pt-8 md:flex-row md:items-center md:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/onboarding/business")}
|
||||
className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-on-surface-variant transition-colors hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
{sd.back}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="max-w-[220px] text-right text-[10px] font-bold uppercase tracking-widest text-outline">
|
||||
{sd.termsNote}
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-3 rounded-xl bg-primary px-10 py-5 font-headline text-lg font-black text-on-primary shadow-xl shadow-primary/20 transition-all hover:scale-[1.02] active:scale-95 disabled:opacity-70 disabled:hover:scale-100"
|
||||
>
|
||||
<span>{submitting ? sd.saving : sd.continue}</span>
|
||||
<span className="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/app/(onboarding)/onboarding/success/page.tsx
Normal file
109
src/app/(onboarding)/onboarding/success/page.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
function formatSubmittedAt() {
|
||||
const now = new Date();
|
||||
const day = now.toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
const time = now.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return `${day}, ${time}`;
|
||||
}
|
||||
|
||||
export default function SuccessPage() {
|
||||
const submittedAt = formatSubmittedAt();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
|
||||
<div className="max-w-2xl w-full">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/10 rounded-full mb-8">
|
||||
<span className="w-2 h-2 rounded-full bg-primary" />
|
||||
<span className="text-xs font-black uppercase tracking-widest text-primary">
|
||||
Submission Complete
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-12 items-start md:items-center">
|
||||
{/* Left: Text */}
|
||||
<div className="flex-1">
|
||||
<h1 className="font-headline font-black text-5xl md:text-6xl text-on-surface leading-tight tracking-tight mb-6">
|
||||
Data Submitted{" "}
|
||||
<span className="text-primary italic">Successfully</span>
|
||||
</h1>
|
||||
<p className="text-on-surface-variant leading-relaxed mb-8 max-w-sm">
|
||||
Profil UMKM institusional Anda telah tercatat dan sedang ditinjau
|
||||
oleh tim verifikasi kami. Setelah disetujui, Anda akan mendapat akses
|
||||
penuh ke protokol{" "}
|
||||
<span className="text-tertiary font-semibold">Trading Desk Alpha</span>.
|
||||
Lanjutkan ke dashboard untuk memantau status Anda.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-3 editorial-gradient text-on-primary font-headline font-bold px-8 py-4 rounded-xl shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all text-lg"
|
||||
>
|
||||
Lanjutkan ke Dashboard
|
||||
<span className="material-symbols-outlined">arrow_forward</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: Visual */}
|
||||
<div className="relative shrink-0">
|
||||
{/* Red card */}
|
||||
<div className="w-48 h-48 bg-primary rounded-2xl flex items-center justify-center shadow-xl shadow-primary/30">
|
||||
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center shadow-md">
|
||||
<span
|
||||
className="material-symbols-outlined text-primary text-4xl"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
check
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="mt-20 flex items-start gap-0">
|
||||
{/* Submitted */}
|
||||
<div className="flex-1">
|
||||
<div className="h-0.5 bg-primary mb-3" />
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-primary">
|
||||
Submitted
|
||||
</p>
|
||||
<p className="text-xs text-on-surface-variant font-medium mt-0.5">
|
||||
{submittedAt}
|
||||
</p>
|
||||
</div>
|
||||
{/* Verification */}
|
||||
<div className="flex-1">
|
||||
<div className="h-0.5 bg-secondary mb-3" />
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-secondary">
|
||||
Verification
|
||||
</p>
|
||||
<p className="text-xs text-on-surface-variant font-medium mt-0.5">
|
||||
In Progress
|
||||
</p>
|
||||
</div>
|
||||
{/* Approval */}
|
||||
<div className="flex-1">
|
||||
<div className="h-0.5 bg-outline-variant mb-3" />
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-outline">
|
||||
Approval
|
||||
</p>
|
||||
<p className="text-xs text-on-surface-variant font-medium mt-0.5">
|
||||
Pending Review
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
src/app/admin/dashboard/page.tsx
Normal file
212
src/app/admin/dashboard/page.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
const reviewQueue = [
|
||||
{ icon: "inventory_2", name: "Artisan Ceramic Vase", vendor: "Kyoto Craft Guild", date: "Oct 24, 2023" },
|
||||
{ icon: "watch", name: "Chronos Obsidian X", vendor: "Titan Horology", date: "Oct 24, 2023" },
|
||||
{ icon: "stroller", name: "MetroGlider S2", vendor: "Urban Mobility Co.", date: "Oct 23, 2023" },
|
||||
{ icon: "coffee", name: "Sumatra Single Origin", vendor: "Emerald Roast House", date: "Oct 23, 2023" },
|
||||
];
|
||||
|
||||
const updateQueue = [
|
||||
{
|
||||
tag: "Price Revision",
|
||||
tagColor: "text-tertiary bg-tertiary-fixed",
|
||||
borderColor: "border-tertiary",
|
||||
name: "Nordic Wool Sweater",
|
||||
vendor: "Oslo Textiles",
|
||||
date: "Oct 24",
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
tag: "Spec Change",
|
||||
tagColor: "text-primary bg-primary-fixed",
|
||||
borderColor: "border-primary",
|
||||
name: "Lumix Ultra Drone",
|
||||
vendor: "AeroDynamics Gmbh",
|
||||
date: "Oct 23",
|
||||
note: "Battery capacity updated (+15%)",
|
||||
},
|
||||
];
|
||||
|
||||
const curatorActions = [
|
||||
{
|
||||
color: "bg-tertiary-fixed",
|
||||
dot: "bg-tertiary",
|
||||
text: 'Listing Approved: "Organic Silk Scarf"',
|
||||
by: "By Sarah J. • 12 mins ago",
|
||||
},
|
||||
{
|
||||
color: "bg-primary-fixed",
|
||||
dot: "bg-primary",
|
||||
text: 'Update Rejected: "Smart Lock Gen 3"',
|
||||
by: "By System Audit • 45 mins ago",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-4xl font-extrabold tracking-tight text-on-surface mb-2">Curator Intelligence</h2>
|
||||
<p className="text-slate-500 text-lg leading-relaxed">
|
||||
Central oversight for global inventory. Reviewing market listings and vendor updates for the Ina Trading ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-primary-container text-on-primary-container px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-sm">bolt</span>
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Live Updates Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
{[
|
||||
{ label: "Total Pending", value: "142", sub: "+12% vs yesterday", subColor: "text-primary" },
|
||||
{ label: "In Review", value: "28", sub: "4 teams active", subColor: "text-tertiary" },
|
||||
{ label: "Approved Today", value: "382", sub: "94% acceptance", subColor: "text-slate-400", valueColor: "text-tertiary" },
|
||||
].map((metric) => (
|
||||
<div key={metric.label} className="bg-surface-container-lowest p-8 rounded-xl relative overflow-hidden shadow-[0px_20px_40px_rgba(25,28,30,0.06)] group">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-slate-50 rounded-bl-full -mr-8 -mt-8 transition-transform group-hover:scale-110" />
|
||||
<p className="text-[10px] uppercase tracking-widest text-slate-400 mb-2 relative z-10">{metric.label}</p>
|
||||
<div className="flex items-baseline gap-2 relative z-10">
|
||||
<h3 className={`text-5xl font-black ${metric.valueColor || "text-on-surface"}`}>{metric.value}</h3>
|
||||
<span className={`text-sm font-bold ${metric.subColor}`}>{metric.sub}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Product Review Queue */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-2xl font-bold tracking-tight">New Product Review Queue</h4>
|
||||
<p className="text-sm text-slate-400 uppercase tracking-widest mt-1">Initial Listing Requests</p>
|
||||
</div>
|
||||
<button className="text-sm font-semibold text-primary hover:underline">View All</button>
|
||||
</div>
|
||||
<div className="bg-surface-container-low rounded-xl overflow-hidden p-1">
|
||||
<div className="bg-surface-container-lowest rounded-lg">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-50">
|
||||
{["Product Name", "Vendor", "Request Date", "Action"].map((h) => (
|
||||
<th key={h} className={`px-6 py-4 text-[10px] font-bold uppercase tracking-[0.1em] text-slate-400 ${h === "Action" ? "text-right" : ""}`}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{reviewQueue.map((item) => (
|
||||
<tr key={item.name} className="group hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-slate-400">{item.icon}</span>
|
||||
</div>
|
||||
<span className="font-bold text-on-surface">{item.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-slate-600 font-medium">{item.vendor}</td>
|
||||
<td className="px-6 py-5 text-slate-500 font-medium">{item.date}</td>
|
||||
<td className="px-6 py-5 text-right">
|
||||
<button className="bg-gradient-to-r from-primary to-primary-container text-white px-5 py-2 rounded-lg text-xs font-bold uppercase tracking-widest shadow-lg shadow-primary/20 transform group-hover:scale-105 transition-all">
|
||||
Take Review
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Queue */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-2xl font-bold tracking-tight">Update Queue</h4>
|
||||
<p className="text-sm text-slate-400 uppercase tracking-widest mt-1">Metadata & Pricing Refinement</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{updateQueue.map((item) => (
|
||||
<div key={item.name} className={`bg-surface-container-lowest p-6 rounded-xl shadow-[0px_20px_40px_rgba(25,28,30,0.06)] border-l-4 ${item.borderColor}`}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className={`text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded mb-2 inline-block ${item.tagColor}`}>
|
||||
{item.tag}
|
||||
</span>
|
||||
<h5 className="text-lg font-bold">{item.name}</h5>
|
||||
<p className="text-xs text-slate-400 font-medium">Vendor: {item.vendor}</p>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase">{item.date}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-50">
|
||||
{item.note
|
||||
? <p className="text-xs text-slate-500 font-medium">{item.note}</p>
|
||||
: <div />
|
||||
}
|
||||
<button className="text-xs font-black uppercase tracking-[0.1em] text-primary hover:text-primary-container transition-colors">
|
||||
Review Changes →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Recent Curator Actions */}
|
||||
<div className="mt-4 bg-surface-container-low p-6 rounded-xl">
|
||||
<h6 className="text-xs font-black uppercase tracking-[0.2em] text-slate-500 mb-6">Recent Curator Actions</h6>
|
||||
<div className="space-y-6 relative">
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-slate-200" />
|
||||
{curatorActions.map((action) => (
|
||||
<div key={action.text} className="relative pl-8">
|
||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full ${action.color} flex items-center justify-center border-4 border-surface-container-low`}>
|
||||
<div className={`w-2 h-2 ${action.dot} rounded-full`} />
|
||||
</div>
|
||||
<p className="text-sm font-bold">{action.text}</p>
|
||||
<p className="text-[10px] text-slate-400 uppercase font-bold tracking-widest">{action.by}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Market Saturation Report Banner */}
|
||||
<div className="mt-20 bg-slate-900 text-white rounded-2xl p-10 flex flex-col md:flex-row items-center justify-between gap-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 blur-[100px] -mr-32 -mt-32" />
|
||||
<div className="relative z-10 max-w-xl">
|
||||
<h4 className="text-3xl font-black mb-4">Market Saturation Report</h4>
|
||||
<p className="text-slate-400 font-medium">
|
||||
Your current review velocity is{" "}
|
||||
<span className="text-white">15% higher</span> than the monthly average.
|
||||
Consider delegating price-based updates to the automated filter.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<button className="bg-white text-slate-950 font-black px-8 py-4 rounded-lg uppercase tracking-widest text-sm hover:bg-primary hover:text-white transition-all">
|
||||
Download PDF Analysis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB */}
|
||||
<button className="fixed bottom-8 right-8 w-16 h-16 bg-primary text-white rounded-full flex items-center justify-center shadow-2xl hover:scale-110 transition-transform z-50 group">
|
||||
<span className="material-symbols-outlined text-3xl group-hover:rotate-90 transition-transform">add</span>
|
||||
<span className="absolute right-20 bg-slate-900 text-white text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
Create Manual Entry
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
src/app/admin/layout.tsx
Normal file
108
src/app/admin/layout.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin/dashboard", icon: "dashboard", label: "Dashboard" },
|
||||
{ href: "/admin/news", icon: "newspaper", label: "News" },
|
||||
{ href: "/admin/places", icon: "map", label: "Places" },
|
||||
{ href: "/admin/review", icon: "rate_review", label: "Review" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("role");
|
||||
sessionStorage.removeItem("token");
|
||||
sessionStorage.removeItem("role");
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface text-on-surface min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="h-screen w-64 fixed left-0 top-0 bg-slate-50 flex flex-col py-8 z-50 border-r border-slate-100">
|
||||
<div className="px-6 mb-10">
|
||||
<Image src="/ina_logo.png" alt="Ina Trading" width={140} height={40} className="object-contain" priority />
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-400 mt-2">Global Curator Admin</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center px-6 py-3 font-semibold transition-colors ${
|
||||
isActive
|
||||
? "text-primary font-bold border-r-4 border-primary bg-white"
|
||||
: "text-slate-500 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined mr-3 text-xl">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-6 pt-6 border-t border-slate-200 space-y-1">
|
||||
<div className="flex items-center mb-5 px-2">
|
||||
<div className="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-white font-black text-sm mr-3 flex-shrink-0">
|
||||
A
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-bold truncate">Admin</p>
|
||||
<p className="text-xs text-slate-400 truncate">Global Curator</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center py-2 text-slate-500 font-medium hover:text-primary transition-colors w-full"
|
||||
>
|
||||
<span className="material-symbols-outlined mr-3 text-xl">logout</span>
|
||||
<span className="text-sm">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Top Header */}
|
||||
<header className="fixed top-0 left-64 right-0 h-16 bg-white/80 backdrop-blur-xl border-b border-slate-100 shadow-sm z-40 flex justify-between items-center px-8 text-sm">
|
||||
<div className="flex items-center flex-1 max-w-xl">
|
||||
<div className="relative w-full">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-lg">search</span>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border-none rounded-lg pl-10 pr-4 py-2 text-sm focus:ring-1 focus:ring-primary/20 focus:outline-none"
|
||||
placeholder="Search markets, products, or vendors..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex space-x-4">
|
||||
<button className="relative text-slate-600 hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
<span className="absolute top-0 right-0 w-2 h-2 bg-primary rounded-full" />
|
||||
</button>
|
||||
<button className="text-slate-600 hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-slate-200 mx-2" />
|
||||
<span className="font-semibold text-primary">Admin</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<main className="ml-64 mt-16 p-8 min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
377
src/app/admin/news/[newsId]/edit/page.tsx
Normal file
377
src/app/admin/news/[newsId]/edit/page.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
|
||||
|
||||
interface NewsForm {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
reporter: string;
|
||||
summary: string;
|
||||
section1: string;
|
||||
section2: string;
|
||||
section3: string;
|
||||
image1: string;
|
||||
image2: string;
|
||||
image3: string;
|
||||
image4: string;
|
||||
image5: string;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function extractImageUrl(data: Record<string, unknown>): string | null {
|
||||
const filename =
|
||||
(data?.data as Record<string, unknown>)?.id ||
|
||||
(data?.data as Record<string, unknown>)?.fileName ||
|
||||
(data?.data as Record<string, unknown>)?.fileId ||
|
||||
data?.fileName ||
|
||||
data?.fileId ||
|
||||
data?.url ||
|
||||
(data?.data as Record<string, unknown>)?.url;
|
||||
if (!filename) return null;
|
||||
if (typeof filename === "string" && filename.startsWith("http")) return filename;
|
||||
return `${API_BASE}/api/v1.0/file/image/${filename}`;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-surface-container-low border border-outline-variant/20 rounded-xl px-4 py-3 text-sm font-medium text-on-surface placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all";
|
||||
|
||||
const labelCls = "block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2";
|
||||
|
||||
function SectionHeader({ step, title }: { step: string; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary text-white flex items-center justify-center text-sm font-black flex-shrink-0">
|
||||
{step}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-on-surface">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSlot({ label, value, onUpload }: { label: string; value: string; onUpload: (url: string) => void }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
const url = extractImageUrl(data);
|
||||
if (!url) throw new Error("URL gambar tidak ditemukan");
|
||||
onUpload(url);
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "Upload gagal");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className={labelCls}>{label}</p>
|
||||
<div
|
||||
className={`relative w-full h-36 rounded-xl border-2 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all overflow-hidden ${
|
||||
value ? "border-tertiary/40 bg-tertiary/5"
|
||||
: uploadError ? "border-error/40 bg-error/5"
|
||||
: "border-outline-variant/30 bg-surface-container-low hover:border-primary/40 hover:bg-primary/5"
|
||||
}`}
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
>
|
||||
{value ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={value} alt={label} className="absolute inset-0 w-full h-full object-cover rounded-xl" />
|
||||
) : uploading ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-primary text-2xl animate-spin">progress_activity</span>
|
||||
<span className="text-[10px] font-bold text-primary">Uploading...</span>
|
||||
</div>
|
||||
) : uploadError ? (
|
||||
<div className="flex flex-col items-center gap-1 px-2 text-center">
|
||||
<span className="material-symbols-outlined text-error text-2xl">error</span>
|
||||
<span className="text-[9px] font-bold text-error leading-tight">{uploadError}</span>
|
||||
<span className="text-[9px] text-slate-400">Tap to retry</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-slate-300 text-3xl">add_photo_alternate</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Upload</span>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||
<span className="material-symbols-outlined text-white text-2xl">edit</span>
|
||||
<span className="text-white text-[10px] font-bold uppercase tracking-wider">Ganti Gambar</span>
|
||||
</div>
|
||||
)}
|
||||
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
{value && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-tertiary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
<span className="text-[10px] font-bold text-tertiary">Gambar berhasil diupload</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminNewsEditPage() {
|
||||
const params = useParams<{ newsId: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState<NewsForm | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingPhase, setSavingPhase] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.newsId) return;
|
||||
// Use cached data from the list page if available
|
||||
const cached = sessionStorage.getItem("editNewsCache");
|
||||
if (cached) {
|
||||
try {
|
||||
const raw = JSON.parse(cached);
|
||||
if (raw.id === params.newsId) {
|
||||
setForm({
|
||||
title: raw?.title || "",
|
||||
subtitle: raw?.subtitle || "",
|
||||
category: raw?.category || "",
|
||||
reporter: raw?.reporter || "",
|
||||
summary: raw?.summary || "",
|
||||
section1: raw?.section1 || "",
|
||||
section2: raw?.section2 || "",
|
||||
section3: raw?.section3 || "",
|
||||
image1: raw?.image1 || "",
|
||||
image2: raw?.image2 || "",
|
||||
image3: raw?.image3 || "",
|
||||
image4: raw?.image4 || "",
|
||||
image5: raw?.image5 || "",
|
||||
});
|
||||
sessionStorage.removeItem("editNewsCache");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setLoadError("Data tidak tersedia. Kembali ke daftar news dan klik Edit lagi.");
|
||||
setLoading(false);
|
||||
}, [params.newsId]);
|
||||
|
||||
function update(patch: Partial<NewsForm>) {
|
||||
setForm((prev) => prev ? { ...prev, ...patch } : prev);
|
||||
}
|
||||
|
||||
async function reuploadImageUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
const filename = url.split("/").pop() || "image.jpg";
|
||||
const file = new File([blob], filename, { type: blob.type || "image/jpeg" });
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const uploadRes = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await uploadRes.json();
|
||||
if (!uploadRes.ok) return null;
|
||||
return (data?.data as Record<string, unknown>)?.fileId as string ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form) return;
|
||||
if (!form.title.trim()) { setError("Judul artikel wajib diisi"); return; }
|
||||
if (!form.section1.trim()) { setError("Section 1 (konten utama) wajib diisi"); return; }
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
// Re-upload all non-empty image URLs to obtain fresh fileIds
|
||||
setSavingPhase("Mengunggah ulang gambar...");
|
||||
const imageSlots = ["image1", "image2", "image3", "image4", "image5"] as const;
|
||||
const fileIds: Record<string, string | null> = {};
|
||||
for (const slot of imageSlots) {
|
||||
const url = form[slot];
|
||||
fileIds[slot] = url ? await reuploadImageUrl(url) : null;
|
||||
}
|
||||
|
||||
setSavingPhase("Menyimpan perubahan...");
|
||||
const payload = {
|
||||
title: form.title,
|
||||
subtitle: form.subtitle || null,
|
||||
category: form.category || null,
|
||||
reporter: form.reporter || null,
|
||||
summary: form.summary || null,
|
||||
section1: form.section1,
|
||||
section2: form.section2 || null,
|
||||
section3: form.section3 || null,
|
||||
image1: fileIds.image1,
|
||||
image2: fileIds.image2,
|
||||
image3: fileIds.image3,
|
||||
image4: fileIds.image4,
|
||||
image5: fileIds.image5,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`/api/admin/news/${params.newsId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "x-auth-token": getToken() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data?.responseDesc || data?.error || "Gagal memperbarui artikel"); return; }
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/admin/news"), 1500);
|
||||
} catch {
|
||||
setError("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingPhase("");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="m-6 flex items-center justify-center h-64">
|
||||
<span className="material-symbols-outlined text-4xl text-slate-300 animate-spin">progress_activity</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loadError) return (
|
||||
<div className="m-6 flex flex-col items-center justify-center h-64 gap-4">
|
||||
<span className="material-symbols-outlined text-4xl text-error">error</span>
|
||||
<p className="text-sm font-semibold text-error">{loadError}</p>
|
||||
<button onClick={() => router.back()} className="text-sm font-bold text-primary hover:underline">Kembali</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!form) return null;
|
||||
|
||||
return (
|
||||
<div className="m-6 space-y-8 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-on-surface">Edit News</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">Perbarui artikel yang sudah diterbitkan</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => router.back()} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section 01 */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="01" title="Informasi Artikel" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Judul *</label>
|
||||
<input value={form.title} onChange={(e) => update({ title: e.target.value })} placeholder="Judul artikel..." className={inputCls} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Subtitle</label>
|
||||
<input value={form.subtitle} onChange={(e) => update({ subtitle: e.target.value })} placeholder="Subtitle artikel..." className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Kategori</label>
|
||||
<input value={form.category} onChange={(e) => update({ category: e.target.value })} placeholder="Contoh: Export, Ekonomi, Store..." className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Reporter / Penulis</label>
|
||||
<input value={form.reporter} onChange={(e) => update({ reporter: e.target.value })} placeholder="Nama reporter..." className={inputCls} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Summary</label>
|
||||
<textarea value={form.summary} onChange={(e) => update({ summary: e.target.value })} placeholder="Ringkasan singkat artikel..." rows={3} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 02 */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="02" title="Konten Artikel" />
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className={labelCls}>Section 1 *</label>
|
||||
<textarea value={form.section1} onChange={(e) => update({ section1: e.target.value })} placeholder="Konten utama artikel..." rows={6} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Section 2</label>
|
||||
<textarea value={form.section2} onChange={(e) => update({ section2: e.target.value })} placeholder="Lanjutan konten..." rows={6} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Section 3</label>
|
||||
<textarea value={form.section3} onChange={(e) => update({ section3: e.target.value })} placeholder="Konten tambahan..." rows={4} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 03 */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="03" title="Gambar Artikel" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{(["image1", "image2", "image3", "image4", "image5"] as const).map((slot, i) => (
|
||||
<ImageSlot key={slot} label={`Gambar ${i + 1}${i === 0 ? " *" : ""}`} value={form[slot]} onUpload={(url) => update({ [slot]: url })} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
Artikel berhasil diperbarui! Mengalihkan...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-end gap-2 pt-2">
|
||||
{savingPhase && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
{savingPhase}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="button" onClick={() => router.back()} disabled={saving} className="px-6 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm uppercase tracking-[0.18em] hover:bg-surface-container-low transition-colors disabled:opacity-40">
|
||||
Batal
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={saving || success} className="bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0">
|
||||
{saving ? (savingPhase || "Menyimpan...") : "Simpan Perubahan"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
src/app/admin/news/new/page.tsx
Normal file
290
src/app/admin/news/new/page.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
|
||||
|
||||
interface NewsForm {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
reporter: string;
|
||||
summary: string;
|
||||
section1: string;
|
||||
section2: string;
|
||||
section3: string;
|
||||
image1: string;
|
||||
image2: string;
|
||||
image3: string;
|
||||
image4: string;
|
||||
image5: string;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function extractImageUrl(data: Record<string, unknown>): string | null {
|
||||
// API returns fileName or data.id as the filename; construct the full URL
|
||||
const filename =
|
||||
(data?.data as Record<string, unknown>)?.id ||
|
||||
(data?.data as Record<string, unknown>)?.fileName ||
|
||||
(data?.data as Record<string, unknown>)?.fileId ||
|
||||
data?.fileName ||
|
||||
data?.fileId ||
|
||||
data?.url ||
|
||||
(data?.data as Record<string, unknown>)?.url;
|
||||
if (!filename) return null;
|
||||
// If already a full URL, return as-is
|
||||
if (typeof filename === "string" && filename.startsWith("http")) return filename;
|
||||
return `${API_BASE}/api/v1.0/file/image/${filename}`;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-surface-container-low border border-outline-variant/20 rounded-xl px-4 py-3 text-sm font-medium text-on-surface placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all";
|
||||
|
||||
const labelCls = "block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2";
|
||||
|
||||
function SectionHeader({ step, title }: { step: string; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary text-white flex items-center justify-center text-sm font-black flex-shrink-0">
|
||||
{step}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-on-surface">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSlot({ label, value, onUpload }: { label: string; value: string; onUpload: (url: string) => void }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
const token = getToken();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": token },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
const url = extractImageUrl(data);
|
||||
if (!url) throw new Error("URL gambar tidak ditemukan dari response");
|
||||
onUpload(url);
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "Upload gagal");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className={labelCls}>{label}</p>
|
||||
<div
|
||||
className={`relative w-full h-36 rounded-xl border-2 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all overflow-hidden ${
|
||||
value
|
||||
? "border-tertiary/40 bg-tertiary/5"
|
||||
: uploadError
|
||||
? "border-error/40 bg-error/5"
|
||||
: "border-outline-variant/30 bg-surface-container-low hover:border-primary/40 hover:bg-primary/5"
|
||||
}`}
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
>
|
||||
{value ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={value} alt={label} className="absolute inset-0 w-full h-full object-cover rounded-xl" />
|
||||
) : uploading ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-primary text-2xl animate-spin">progress_activity</span>
|
||||
<span className="text-[10px] font-bold text-primary">Uploading...</span>
|
||||
</div>
|
||||
) : uploadError ? (
|
||||
<div className="flex flex-col items-center gap-1 px-2 text-center">
|
||||
<span className="material-symbols-outlined text-error text-2xl">error</span>
|
||||
<span className="text-[9px] font-bold text-error leading-tight">{uploadError}</span>
|
||||
<span className="text-[9px] text-slate-400">Tap to retry</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-slate-300 text-3xl">add_photo_alternate</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Upload</span>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||
<span className="material-symbols-outlined text-white text-2xl">edit</span>
|
||||
<span className="text-white text-[10px] font-bold uppercase tracking-wider">Ganti Gambar</span>
|
||||
</div>
|
||||
)}
|
||||
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
{value && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-tertiary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
<span className="text-[10px] font-bold text-tertiary">Gambar berhasil diupload</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminNewsNewPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<NewsForm>({
|
||||
title: "", subtitle: "", category: "", reporter: "", summary: "",
|
||||
section1: "", section2: "", section3: "",
|
||||
image1: "", image2: "", image3: "", image4: "", image5: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
function update(patch: Partial<NewsForm>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.title.trim()) { setError("Judul artikel wajib diisi"); return; }
|
||||
if (!form.section1.trim()) { setError("Section 1 (konten utama) wajib diisi"); return; }
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const payload = {
|
||||
title: form.title,
|
||||
subtitle: form.subtitle || null,
|
||||
category: form.category || null,
|
||||
reporter: form.reporter || null,
|
||||
summary: form.summary || null,
|
||||
section1: form.section1,
|
||||
section2: form.section2 || null,
|
||||
section3: form.section3 || null,
|
||||
image1: form.image1 || null,
|
||||
image2: form.image2 || null,
|
||||
image3: form.image3 || null,
|
||||
image4: form.image4 || null,
|
||||
image5: form.image5 || null,
|
||||
};
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch("/api/admin/news", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-auth-token": token },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data?.responseDesc || data?.error || "Gagal menerbitkan news"); return; }
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/admin/news"), 1500);
|
||||
} catch {
|
||||
setError("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-6 space-y-8 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-on-surface">Post News</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">Buat dan terbitkan artikel baru</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => router.back()} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
Artikel berhasil diterbitkan! Mengalihkan...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 01: Info */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="01" title="Informasi Artikel" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Judul *</label>
|
||||
<input value={form.title} onChange={(e) => update({ title: e.target.value })} placeholder="Judul artikel..." className={inputCls} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Subtitle</label>
|
||||
<input value={form.subtitle} onChange={(e) => update({ subtitle: e.target.value })} placeholder="Subtitle artikel..." className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Kategori</label>
|
||||
<input value={form.category} onChange={(e) => update({ category: e.target.value })} placeholder="Contoh: Export, Ekonomi, Store..." className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Reporter / Penulis</label>
|
||||
<input value={form.reporter} onChange={(e) => update({ reporter: e.target.value })} placeholder="Nama reporter..." className={inputCls} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Summary</label>
|
||||
<textarea value={form.summary} onChange={(e) => update({ summary: e.target.value })} placeholder="Ringkasan singkat artikel..." rows={3} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 02: Konten */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="02" title="Konten Artikel" />
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className={labelCls}>Section 1 *</label>
|
||||
<textarea value={form.section1} onChange={(e) => update({ section1: e.target.value })} placeholder="Konten utama artikel..." rows={6} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Section 2</label>
|
||||
<textarea value={form.section2} onChange={(e) => update({ section2: e.target.value })} placeholder="Lanjutan konten..." rows={6} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Section 3</label>
|
||||
<textarea value={form.section3} onChange={(e) => update({ section3: e.target.value })} placeholder="Konten tambahan..." rows={4} className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 03: Gambar */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="03" title="Gambar Artikel" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{(["image1", "image2", "image3", "image4", "image5"] as const).map((slot, i) => (
|
||||
<ImageSlot key={slot} label={`Gambar ${i + 1}${i === 0 ? " *" : ""}`} value={form[slot]} onUpload={(url) => update({ [slot]: url })} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-4 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="px-6 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm uppercase tracking-[0.18em] hover:bg-surface-container-low transition-colors">
|
||||
Batal
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={saving || success} className="bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0">
|
||||
{saving ? "Menerbitkan..." : "Terbitkan Artikel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
src/app/admin/news/page.tsx
Normal file
274
src/app/admin/news/page.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface NewsRow {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
category: string;
|
||||
reporter: string;
|
||||
image1: string | null;
|
||||
section1: string | null;
|
||||
section2: string | null;
|
||||
section3: string | null;
|
||||
summary: string | null;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function categoryColor(category: string) {
|
||||
const c = category.toLowerCase();
|
||||
if (c === "export") return "bg-secondary-fixed text-on-secondary-fixed-variant";
|
||||
if (c === "ekonomi") return "bg-primary-fixed text-on-primary-fixed-variant";
|
||||
if (c === "store") return "bg-tertiary-fixed text-on-tertiary-fixed-variant";
|
||||
if (c === "business") return "bg-slate-200 text-slate-700";
|
||||
return "bg-tertiary-fixed text-on-tertiary-fixed-variant";
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() || "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export default function AdminNewsPage() {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState<NewsRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalItem, setTotalItem] = useState(0);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch(`/api/admin/news?page=${page}&size=${pageSize}`, {
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data?.rows) ? data.rows : []);
|
||||
setTotalItem(data?.totalItem ?? 0);
|
||||
setTotalPage(data?.totalPage ?? 1);
|
||||
} catch {
|
||||
setError("Gagal memuat data news");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [page]);
|
||||
|
||||
const startEntry = page * pageSize + 1;
|
||||
const endEntry = Math.min((page + 1) * pageSize, totalItem);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<section className="flex justify-between items-end mb-12">
|
||||
<div>
|
||||
<nav className="flex items-center space-x-2 text-[10px] font-bold uppercase tracking-widest text-primary mb-3">
|
||||
<span>Curator Dashboard</span>
|
||||
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
|
||||
<span className="text-slate-400">Global News Feed</span>
|
||||
</nav>
|
||||
<h2 className="text-4xl font-extrabold tracking-tight text-on-surface leading-none">News Intelligence</h2>
|
||||
<p className="mt-4 text-secondary max-w-xl text-base leading-relaxed">
|
||||
Manage and curate high-impact editorial content across Ina Trading's global market intelligence network.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/news/new"
|
||||
className="bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-lg font-bold flex items-center space-x-3 shadow-lg shadow-primary/20 hover:scale-[1.02] transition-transform"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">add</span>
|
||||
<span className="tracking-tight">Post News</span>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl border-l-4 border-primary">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Active Articles</span>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-extrabold tracking-tighter">{totalItem.toLocaleString()}</span>
|
||||
<span className="text-tertiary text-xs font-bold">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Pending Review</span>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-extrabold tracking-tighter">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Global Impact Score</span>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-extrabold tracking-tighter">8.2</span>
|
||||
<span className="text-slate-400 text-xs font-medium">/ 10 Avg.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-surface-container-lowest shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-2xl overflow-hidden">
|
||||
<div className="p-8 border-b border-surface-container">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold tracking-tight">Editorial Registry</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="flex items-center space-x-2 text-xs font-bold uppercase tracking-widest text-secondary hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-sm">filter_list</span>
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block animate-spin">progress_activity</span>
|
||||
<p className="text-sm font-medium">Memuat data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-16 text-center text-error">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">error</span>
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">newspaper</span>
|
||||
<p className="text-sm font-medium">Belum ada artikel news</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-surface-container-low/50">
|
||||
{["Title", "Reporter", "Category", "Actions"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-8 py-5 text-[10px] font-black uppercase tracking-[0.15em] text-slate-500 ${h === "Actions" ? "text-right" : ""}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-container">
|
||||
{rows.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-surface-container-low transition-colors group">
|
||||
{/* Title */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{item.image1 ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={item.image1}
|
||||
alt={item.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0 bg-slate-100"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-slate-300 text-xl">image</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-bold text-on-surface tracking-tight leading-snug max-w-xs truncate">{item.title}</span>
|
||||
{item.subtitle && (
|
||||
<span className="text-[10px] font-medium text-slate-400 mt-1 uppercase tracking-wider truncate max-w-xs">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Reporter */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-[10px] font-bold text-slate-500 uppercase flex-shrink-0">
|
||||
{initials(item.reporter)}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-on-surface">{item.reporter}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Category */}
|
||||
<td className="px-8 py-6">
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${categoryColor(item.category)}`}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-8 py-6 text-right">
|
||||
<Link
|
||||
href={`/admin/news/${item.id}/edit`}
|
||||
onClick={() => sessionStorage.setItem("editNewsCache", JSON.stringify(item))}
|
||||
className="inline-flex items-center gap-1.5 bg-surface-container px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-on-surface hover:bg-primary hover:text-white transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">edit</span>
|
||||
Edit
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && rows.length > 0 && (
|
||||
<div className="p-6 bg-surface-container-low/30 flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">
|
||||
Showing{" "}
|
||||
<span className="font-bold text-on-surface">{startEntry}-{endEntry}</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-bold text-on-surface">{totalItem.toLocaleString()}</span> entries
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="w-8 h-8 flex items-center justify-center rounded border border-surface-container hover:bg-white transition-colors text-slate-400 disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPage(i)}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded text-xs font-bold transition-colors ${
|
||||
i === page
|
||||
? "bg-primary text-white"
|
||||
: "border border-surface-container hover:bg-white text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPage - 1, p + 1))}
|
||||
disabled={page >= totalPage - 1}
|
||||
className="w-8 h-8 flex items-center justify-center rounded border border-surface-container hover:bg-white transition-colors text-slate-400 disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
600
src/app/admin/places/PlaceForm.tsx
Normal file
600
src/app/admin/places/PlaceForm.tsx
Normal file
@ -0,0 +1,600 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { COUNTRIES } from "@/lib/countries";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
|
||||
|
||||
function extractImageUrl(data: Record<string, unknown>): string | null {
|
||||
const filename =
|
||||
(data?.data as Record<string, unknown>)?.id ||
|
||||
(data?.data as Record<string, unknown>)?.fileName ||
|
||||
(data?.data as Record<string, unknown>)?.fileId ||
|
||||
data?.fileName ||
|
||||
data?.fileId ||
|
||||
data?.url ||
|
||||
(data?.data as Record<string, unknown>)?.url;
|
||||
if (!filename) return null;
|
||||
if (typeof filename === "string" && filename.startsWith("http")) return filename;
|
||||
return `${API_BASE}/api/v1.0/file/image/${filename}`;
|
||||
}
|
||||
|
||||
export interface PlaceFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
contact: string;
|
||||
address: string;
|
||||
country: string;
|
||||
province: string;
|
||||
provinceId: string;
|
||||
city: string;
|
||||
cityId: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
status: string;
|
||||
image1: string;
|
||||
image2: string;
|
||||
image3: string;
|
||||
image4: string;
|
||||
image5: string;
|
||||
}
|
||||
|
||||
interface Province {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface City {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const defaultPlaceForm: PlaceFormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
type: "",
|
||||
contact: "",
|
||||
address: "",
|
||||
country: "Indonesia",
|
||||
province: "",
|
||||
provinceId: "",
|
||||
city: "",
|
||||
cityId: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
status: "APPROVED",
|
||||
image1: "",
|
||||
image2: "",
|
||||
image3: "",
|
||||
image4: "",
|
||||
image5: "",
|
||||
};
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-surface-container-low border border-outline-variant/20 rounded-xl px-4 py-3 text-sm font-medium text-on-surface placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all";
|
||||
|
||||
const labelCls = "block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2";
|
||||
|
||||
function SectionHeader({ step, title }: { step: string; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary text-white flex items-center justify-center text-sm font-black flex-shrink-0">
|
||||
{step}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-on-surface">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSlot({
|
||||
label,
|
||||
value,
|
||||
onUpload,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onUpload: (url: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
const url = extractImageUrl(data);
|
||||
if (!url) throw new Error("URL gambar tidak ditemukan");
|
||||
onUpload(url);
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "Upload gagal");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className={labelCls}>{label}</p>
|
||||
<div
|
||||
className={`relative w-full h-36 rounded-xl border-2 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all overflow-hidden ${
|
||||
value ? "border-tertiary/40 bg-tertiary/5"
|
||||
: uploadError ? "border-error/40 bg-error/5"
|
||||
: "border-outline-variant/30 bg-surface-container-low hover:border-primary/40 hover:bg-primary/5"
|
||||
}`}
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
>
|
||||
{value ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={value} alt={label} className="absolute inset-0 w-full h-full object-cover rounded-xl" />
|
||||
) : uploading ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-primary text-2xl animate-spin">progress_activity</span>
|
||||
<span className="text-[10px] font-bold text-primary">Uploading...</span>
|
||||
</div>
|
||||
) : uploadError ? (
|
||||
<div className="flex flex-col items-center gap-1 px-2 text-center">
|
||||
<span className="material-symbols-outlined text-error text-2xl">error</span>
|
||||
<span className="text-[9px] font-bold text-error leading-tight">{uploadError}</span>
|
||||
<span className="text-[9px] text-slate-400">Tap to retry</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-slate-300 text-3xl">add_photo_alternate</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Upload</span>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||
<span className="material-symbols-outlined text-white text-2xl">edit</span>
|
||||
<span className="text-white text-[10px] font-bold uppercase tracking-wider">Ganti Gambar</span>
|
||||
</div>
|
||||
)}
|
||||
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
{value && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-tertiary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
<span className="text-[10px] font-bold text-tertiary">Gambar berhasil diupload</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaceFormProps {
|
||||
initialData?: PlaceFormState;
|
||||
placeId?: string;
|
||||
pageTitle: string;
|
||||
pageSubtitle: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
successMessage: string;
|
||||
apiMethod: "POST" | "PUT";
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export function PlaceForm({
|
||||
initialData,
|
||||
pageTitle,
|
||||
pageSubtitle,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
successMessage,
|
||||
apiMethod,
|
||||
apiUrl,
|
||||
}: PlaceFormProps) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<PlaceFormState>(initialData ?? defaultPlaceForm);
|
||||
const [provinces, setProvinces] = useState<Province[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [loadingProvinces, setLoadingProvinces] = useState(false);
|
||||
const [loadingCities, setLoadingCities] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingPhase, setSavingPhase] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const isIndonesia = form.country === "Indonesia";
|
||||
|
||||
// Sync form when initialData arrives (edit page)
|
||||
useEffect(() => {
|
||||
if (initialData) setForm(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
// Load provinces when Indonesia is selected
|
||||
useEffect(() => {
|
||||
if (!isIndonesia) {
|
||||
setProvinces([]);
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setLoadingProvinces(true);
|
||||
fetch("/api/locations/provinces", { headers: { "x-auth-token": getToken() } })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProvinces(Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => setProvinces([]))
|
||||
.finally(() => setLoadingProvinces(false));
|
||||
}, [isIndonesia]);
|
||||
|
||||
// Load cities when provinceId changes
|
||||
useEffect(() => {
|
||||
if (!isIndonesia || !form.provinceId) {
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setLoadingCities(true);
|
||||
fetch(`/api/locations/cities?provinceId=${form.provinceId}`, {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setCities(Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => setCities([]))
|
||||
.finally(() => setLoadingCities(false));
|
||||
}, [isIndonesia, form.provinceId]);
|
||||
|
||||
function update(patch: Partial<PlaceFormState>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
async function reuploadImageUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
const filename = url.split("/").pop() || "image.jpg";
|
||||
const file = new File([blob], filename, { type: blob.type || "image/jpeg" });
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const uploadRes = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: formData,
|
||||
});
|
||||
const data = await uploadRes.json();
|
||||
if (!uploadRes.ok) return null;
|
||||
return (data?.data as Record<string, unknown>)?.fileId as string ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim()) {
|
||||
setError("Nama lokasi wajib diisi");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
// Re-upload all non-empty image URLs to obtain fresh fileIds
|
||||
setSavingPhase("Mengunggah ulang gambar...");
|
||||
const imageSlots = ["image1", "image2", "image3", "image4", "image5"] as const;
|
||||
const fileIds: Record<string, string | null> = {};
|
||||
for (const slot of imageSlots) {
|
||||
const url = form[slot];
|
||||
fileIds[slot] = url ? await reuploadImageUrl(url) : null;
|
||||
}
|
||||
|
||||
setSavingPhase("Menyimpan perubahan...");
|
||||
const payload: Record<string, string | number | null> = {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
type: form.type || null,
|
||||
contact: form.contact || null,
|
||||
address: form.address || null,
|
||||
country: form.country || null,
|
||||
province: form.province || null,
|
||||
city: form.city || null,
|
||||
latitude: form.latitude ? parseFloat(form.latitude) : null,
|
||||
longitude: form.longitude ? parseFloat(form.longitude) : null,
|
||||
status: form.status || null,
|
||||
image1: fileIds.image1,
|
||||
image2: fileIds.image2,
|
||||
image3: fileIds.image3,
|
||||
image4: fileIds.image4,
|
||||
image5: fileIds.image5,
|
||||
};
|
||||
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch(apiUrl, {
|
||||
method: apiMethod,
|
||||
headers: { "Content-Type": "application/json", "x-auth-token": token },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data?.responseDesc || data?.error || "Gagal menyimpan lokasi");
|
||||
return;
|
||||
}
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/admin/places"), 1500);
|
||||
} catch {
|
||||
setError("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSavingPhase("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-6 space-y-8 pb-10">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-on-surface">{pageTitle}</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">{pageSubtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section 01: Informasi Dasar */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="01" title="Informasi Lokasi" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Nama Lokasi *</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
placeholder="Nama tempat / lokasi..."
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Deskripsi</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
placeholder="Deskripsi lokasi..."
|
||||
rows={4}
|
||||
className={`${inputCls} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Tipe Lokasi</label>
|
||||
<select value={form.type} onChange={(e) => update({ type: e.target.value })} className={inputCls}>
|
||||
<option value="">Pilih tipe...</option>
|
||||
{["Shop", "Restaurant", "Park", "Business", "Worship", "Museum", "Hotel", "Hospital", "School", "Other"].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Kontak</label>
|
||||
<input
|
||||
value={form.contact}
|
||||
onChange={(e) => update({ contact: e.target.value })}
|
||||
placeholder="Nomor telepon / WhatsApp..."
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Status</label>
|
||||
<select value={form.status} onChange={(e) => update({ status: e.target.value })} className={inputCls}>
|
||||
<option value="APPROVED">APPROVED</option>
|
||||
<option value="PENDING">PENDING</option>
|
||||
<option value="REJECTED">REJECTED</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 02: Alamat & Geolokasi */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="02" title="Alamat & Geolokasi" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Country */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Negara</label>
|
||||
<select
|
||||
value={form.country}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
country: e.target.value,
|
||||
province: "",
|
||||
provinceId: "",
|
||||
city: "",
|
||||
cityId: "",
|
||||
})
|
||||
}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">Pilih negara...</option>
|
||||
{COUNTRIES.map((c) => (
|
||||
<option key={c.code} value={c.name}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Province */}
|
||||
<div>
|
||||
<label className={labelCls}>Provinsi</label>
|
||||
{isIndonesia ? (
|
||||
<select
|
||||
value={form.provinceId}
|
||||
onChange={(e) => {
|
||||
const selected = provinces.find((p) => p.id === e.target.value);
|
||||
update({
|
||||
provinceId: e.target.value,
|
||||
province: selected?.name || "",
|
||||
city: "",
|
||||
cityId: "",
|
||||
});
|
||||
}}
|
||||
disabled={loadingProvinces}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">
|
||||
{loadingProvinces ? "Memuat provinsi..." : "Pilih provinsi..."}
|
||||
</option>
|
||||
{provinces.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.province}
|
||||
onChange={(e) => update({ province: e.target.value })}
|
||||
placeholder="Nama provinsi / state..."
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div>
|
||||
<label className={labelCls}>Kota</label>
|
||||
{isIndonesia ? (
|
||||
<select
|
||||
value={form.cityId}
|
||||
onChange={(e) => {
|
||||
const selected = cities.find((c) => c.id === e.target.value);
|
||||
update({ cityId: e.target.value, city: selected?.name || "" });
|
||||
}}
|
||||
disabled={!form.provinceId || loadingCities}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">
|
||||
{!form.provinceId
|
||||
? "Pilih provinsi dulu..."
|
||||
: loadingCities
|
||||
? "Memuat kota..."
|
||||
: "Pilih kota..."}
|
||||
</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.city}
|
||||
onChange={(e) => update({ city: e.target.value })}
|
||||
placeholder="Nama kota..."
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Alamat Lengkap</label>
|
||||
<textarea
|
||||
value={form.address}
|
||||
onChange={(e) => update({ address: e.target.value })}
|
||||
placeholder="Alamat lengkap..."
|
||||
rows={3}
|
||||
className={`${inputCls} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lat/Lng */}
|
||||
<div>
|
||||
<label className={labelCls}>Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.latitude}
|
||||
onChange={(e) => update({ latitude: e.target.value })}
|
||||
placeholder="Contoh: -6.2088"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.longitude}
|
||||
onChange={(e) => update({ longitude: e.target.value })}
|
||||
placeholder="Contoh: 106.8456"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 03: Gambar */}
|
||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8">
|
||||
<SectionHeader step="03" title="Gambar Lokasi" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{(["image1", "image2", "image3", "image4", "image5"] as const).map((slot, i) => (
|
||||
<ImageSlot
|
||||
key={slot}
|
||||
label={`Gambar ${i + 1}${i === 0 ? " *" : ""}`}
|
||||
value={form[slot]}
|
||||
onUpload={(url) => update({ [slot]: url })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
{success && (
|
||||
<div className="p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-end gap-2 pt-2">
|
||||
{savingPhase && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
{savingPhase}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm uppercase tracking-[0.18em] hover:bg-surface-container-low transition-colors disabled:opacity-40"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || success}
|
||||
className="bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
|
||||
>
|
||||
{saving ? (savingPhase || submittingLabel) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/app/admin/places/[placeId]/edit/page.tsx
Normal file
81
src/app/admin/places/[placeId]/edit/page.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { PlaceForm, PlaceFormState, defaultPlaceForm } from "../../PlaceForm";
|
||||
|
||||
function getCachedPlace(placeId: string): PlaceFormState | null {
|
||||
if (typeof window === "undefined" || !placeId) return null;
|
||||
const cached = sessionStorage.getItem("editPlaceCache");
|
||||
if (!cached) return null;
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(cached) as Record<string, unknown>;
|
||||
if (raw.id !== placeId) return null;
|
||||
sessionStorage.removeItem("editPlaceCache");
|
||||
|
||||
return {
|
||||
name: typeof raw.name === "string" ? raw.name : "",
|
||||
description: typeof raw.description === "string" ? raw.description : "",
|
||||
type: typeof raw.type === "string" ? raw.type : "",
|
||||
contact: typeof raw.contact === "string" ? raw.contact : "",
|
||||
address: typeof raw.address === "string" ? raw.address : "",
|
||||
country: typeof raw.country === "string" ? raw.country : "Indonesia",
|
||||
province: typeof raw.province === "string" ? raw.province : "",
|
||||
provinceId: "",
|
||||
city: typeof raw.city === "string" ? raw.city : "",
|
||||
cityId: "",
|
||||
latitude: raw.latitude != null ? String(raw.latitude) : "",
|
||||
longitude: raw.longitude != null ? String(raw.longitude) : "",
|
||||
status: typeof raw.status === "string" ? raw.status : "APPROVED",
|
||||
image1: typeof raw.image1 === "string" ? raw.image1 : "",
|
||||
image2: typeof raw.image2 === "string" ? raw.image2 : "",
|
||||
image3: typeof raw.image3 === "string" ? raw.image3 : "",
|
||||
image4: typeof raw.image4 === "string" ? raw.image4 : "",
|
||||
image5: typeof raw.image5 === "string" ? raw.image5 : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminPlacesEditPage() {
|
||||
const params = useParams<{ placeId: string }>();
|
||||
const [initialData] = useState<PlaceFormState | null>(() =>
|
||||
getCachedPlace(params.placeId)
|
||||
);
|
||||
const loading = false;
|
||||
const loadError = initialData
|
||||
? ""
|
||||
: "Data tidak tersedia. Kembali ke daftar lokasi dan klik Edit lagi.";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="m-6 flex items-center justify-center h-64">
|
||||
<span className="material-symbols-outlined text-4xl text-slate-300 animate-spin">progress_activity</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="m-6 flex flex-col items-center justify-center h-64 gap-4">
|
||||
<span className="material-symbols-outlined text-4xl text-error">error</span>
|
||||
<p className="text-sm font-semibold text-error">{loadError}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlaceForm
|
||||
initialData={initialData ?? defaultPlaceForm}
|
||||
pageTitle="Edit Place"
|
||||
pageSubtitle="Perbarui informasi lokasi"
|
||||
submitLabel="Simpan Perubahan"
|
||||
submittingLabel="Menyimpan..."
|
||||
successMessage="Lokasi berhasil diperbarui! Mengalihkan..."
|
||||
apiMethod="PUT"
|
||||
apiUrl={`/api/admin/places/${params.placeId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
src/app/admin/places/new/page.tsx
Normal file
18
src/app/admin/places/new/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceForm, defaultPlaceForm } from "../PlaceForm";
|
||||
|
||||
export default function AdminPlacesNewPage() {
|
||||
return (
|
||||
<PlaceForm
|
||||
initialData={defaultPlaceForm}
|
||||
pageTitle="Add New Place"
|
||||
pageSubtitle="Tambahkan lokasi baru ke dalam direktori"
|
||||
submitLabel="Simpan Lokasi"
|
||||
submittingLabel="Menyimpan..."
|
||||
successMessage="Lokasi berhasil ditambahkan! Mengalihkan..."
|
||||
apiMethod="POST"
|
||||
apiUrl="/api/admin/places"
|
||||
/>
|
||||
);
|
||||
}
|
||||
283
src/app/admin/places/page.tsx
Normal file
283
src/app/admin/places/page.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface PlaceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string | null;
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
country: string | null;
|
||||
status: string | null;
|
||||
image1: string | null;
|
||||
contact: string | null;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function statusBadge(status: string | null) {
|
||||
switch (status) {
|
||||
case "APPROVED":
|
||||
return "bg-tertiary-fixed text-on-tertiary-fixed-variant";
|
||||
case "PENDING":
|
||||
return "bg-primary-fixed text-on-primary-fixed-variant";
|
||||
case "REJECTED":
|
||||
return "bg-error-container text-on-error-container";
|
||||
default:
|
||||
return "bg-slate-200 text-slate-600";
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminPlacesPage() {
|
||||
const [rows, setRows] = useState<PlaceRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalItem, setTotalItem] = useState(0);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch(`/api/admin/places?page=${page}&size=${pageSize}`, {
|
||||
headers: { "x-auth-token": token },
|
||||
});
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data?.rows) ? data.rows : []);
|
||||
setTotalItem(data?.totalItem ?? 0);
|
||||
setTotalPage(data?.totalPage ?? 1);
|
||||
} catch {
|
||||
setError("Gagal memuat data lokasi");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [page]);
|
||||
|
||||
const startEntry = page * pageSize + 1;
|
||||
const endEntry = Math.min((page + 1) * pageSize, totalItem);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<section className="flex justify-between items-end mb-12">
|
||||
<div>
|
||||
<nav className="flex items-center space-x-2 text-[10px] font-bold uppercase tracking-widest text-primary mb-3">
|
||||
<span>Curator Dashboard</span>
|
||||
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
|
||||
<span className="text-slate-400">Global Places</span>
|
||||
</nav>
|
||||
<h2 className="text-4xl font-extrabold tracking-tight text-on-surface leading-none">Places Intelligence</h2>
|
||||
<p className="mt-4 text-secondary max-w-xl text-base leading-relaxed">
|
||||
Manage locations, tourist destinations, and business spots across Ina Trading's global network.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/places/new"
|
||||
className="bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-lg font-bold flex items-center space-x-3 shadow-lg shadow-primary/20 hover:scale-[1.02] transition-transform"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">add</span>
|
||||
<span className="tracking-tight">Add Place</span>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl border-l-4 border-primary">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Total Locations</span>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-extrabold tracking-tighter">{totalItem.toLocaleString()}</span>
|
||||
<span className="text-tertiary text-xs font-bold">Entries</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Approved</span>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-extrabold tracking-tighter">
|
||||
{rows.filter((r) => r.status === "APPROVED").length}
|
||||
</span>
|
||||
<span className="text-tertiary text-xs font-bold">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-xl">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block mb-2">Showing Page</span>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-extrabold tracking-tighter">{page + 1}</span>
|
||||
<span className="text-slate-400 text-xs font-medium">/ {totalPage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-surface-container-lowest shadow-[0px_20px_40px_rgba(25,28,30,0.06)] rounded-2xl overflow-hidden">
|
||||
<div className="p-8 border-b border-surface-container">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold tracking-tight">Location Registry</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="flex items-center space-x-2 text-xs font-bold uppercase tracking-widest text-secondary hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-sm">filter_list</span>
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block animate-spin">progress_activity</span>
|
||||
<p className="text-sm font-medium">Memuat data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-16 text-center text-error">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">error</span>
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">map</span>
|
||||
<p className="text-sm font-medium">Belum ada lokasi</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-surface-container-low/50">
|
||||
{["Place", "Type", "Location", "Status", "Actions"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-8 py-5 text-[10px] font-black uppercase tracking-[0.15em] text-slate-500 ${h === "Actions" ? "text-right" : ""}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-container">
|
||||
{rows.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-surface-container-low transition-colors group">
|
||||
{/* Place */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{item.image1 ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={item.image1}
|
||||
alt={item.name}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0 bg-slate-100"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-slate-300 text-xl">place</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-on-surface tracking-tight leading-snug truncate max-w-xs">{item.name}</p>
|
||||
{item.description && (
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 truncate max-w-xs">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Type */}
|
||||
<td className="px-8 py-6">
|
||||
{item.type ? (
|
||||
<span className="px-3 py-1 rounded-full bg-secondary-fixed text-on-secondary-fixed-variant text-[10px] font-black uppercase tracking-widest">
|
||||
{item.type}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-300 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Location */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-on-surface">
|
||||
{[item.city, item.province].filter(Boolean).join(", ") || "—"}
|
||||
</span>
|
||||
{item.country && (
|
||||
<span className="text-[10px] text-slate-400 mt-0.5">{item.country}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-8 py-6">
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${statusBadge(item.status)}`}>
|
||||
{item.status || "—"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-8 py-6 text-right">
|
||||
<Link
|
||||
href={`/admin/places/${item.id}/edit`}
|
||||
onClick={() => sessionStorage.setItem("editPlaceCache", JSON.stringify(item))}
|
||||
className="inline-flex items-center gap-1.5 bg-surface-container px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-on-surface hover:bg-primary hover:text-white transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">edit</span>
|
||||
Edit
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && rows.length > 0 && (
|
||||
<div className="p-6 bg-surface-container-low/30 flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">
|
||||
Showing{" "}
|
||||
<span className="font-bold text-on-surface">{startEntry}–{endEntry}</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-bold text-on-surface">{totalItem.toLocaleString()}</span> entries
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="w-8 h-8 flex items-center justify-center rounded border border-surface-container hover:bg-white transition-colors text-slate-400 disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPage(i)}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded text-xs font-bold transition-colors ${
|
||||
i === page
|
||||
? "bg-primary text-white"
|
||||
: "border border-surface-container hover:bg-white text-on-surface"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPage - 1, p + 1))}
|
||||
disabled={page >= totalPage - 1}
|
||||
className="w-8 h-8 flex items-center justify-center rounded border border-surface-container hover:bg-white transition-colors text-slate-400 disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
429
src/app/admin/review/[productId]/page.tsx
Normal file
429
src/app/admin/review/[productId]/page.tsx
Normal file
@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function imgUrl(id: string | null | undefined) {
|
||||
if (!id) return null;
|
||||
if (id.startsWith("http")) return id;
|
||||
return `${API_BASE}/api/v1.0/file/image/${id}`;
|
||||
}
|
||||
|
||||
// ─── Shared sub-components ───────────────────────────────────────────────────
|
||||
|
||||
function Row({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
||||
if (value === "" || value === undefined || value === null) return null;
|
||||
const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value);
|
||||
return (
|
||||
<div className="flex justify-between gap-4 py-2.5 border-b border-slate-100 last:border-0 text-sm">
|
||||
<span className="text-slate-500 font-medium flex-shrink-0">{label}</span>
|
||||
<span className="font-semibold text-on-surface text-right">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ title, accent, children }: { title: string; accent?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={`rounded-xl border shadow-sm p-5 ${accent ? "border-primary/30 bg-primary/5" : "bg-white border-slate-100"}`}>
|
||||
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] mb-4 pb-3 border-b ${accent ? "text-primary border-primary/20" : "text-slate-400 border-slate-100"}`}>
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function ProductColumn({ product, label, accent }: { product: any; label: string; accent?: boolean }) {
|
||||
if (!product) return (
|
||||
<div className="flex-1 flex items-center justify-center py-20 text-slate-400 text-sm">Memuat data...</div>
|
||||
);
|
||||
|
||||
const models = Array.isArray(product.productModels) ? product.productModels : [];
|
||||
const images = Array.isArray(product.productImages) ? product.productImages : [];
|
||||
const features = Array.isArray(product.productFeatures) ? product.productFeatures : [];
|
||||
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords : [];
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Column label */}
|
||||
<div className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-center ${accent ? "bg-primary text-white" : "bg-slate-100 text-slate-500"}`}>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{(images.length > 0 || imgUrl(product.imageId)) && (
|
||||
<SectionCard title="Gambar Produk" accent={accent}>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(images.length > 0 ? images : [{ imageId: product.imageId }]).map((img: { imageId?: string }, i: number) => {
|
||||
const url = imgUrl(img.imageId);
|
||||
if (!url) return null;
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img key={i} src={url} alt={`img-${i}`} className="w-20 h-20 object-cover rounded-lg border border-slate-100" />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<SectionCard title="Detail Produk" accent={accent}>
|
||||
<Row label="Nama" value={product.name} />
|
||||
<Row label="Deskripsi" value={product.description} />
|
||||
<Row label="Kategori" value={product.subCategory?.category?.name} />
|
||||
<Row label="Sub Kategori" value={product.subCategory?.name} />
|
||||
<Row label="Brand New" value={product.isNew} />
|
||||
<Row label="Eligible to Export" value={product.isEligibleToExport} />
|
||||
<Row label="Pre-order" value={product.isPreOrder} />
|
||||
{product.isPreOrder && <Row label="Pre-order Days" value={product.preOrderDay} />}
|
||||
<Row label="State" value={product.state} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Features */}
|
||||
{features.length > 0 && (
|
||||
<SectionCard title="Fitur Produk" accent={accent}>
|
||||
<ul className="space-y-1.5">
|
||||
{features.map((f: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className={`material-symbols-outlined text-base mt-0.5 ${accent ? "text-primary" : "text-slate-400"}`} style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{keywords.length > 0 && (
|
||||
<SectionCard title="Keywords" accent={accent}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywords.map((k: string) => (
|
||||
<span key={k} className={`px-3 py-1 rounded-full text-xs font-bold ${accent ? "bg-primary/10 text-primary" : "bg-slate-100 text-slate-600"}`}>{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Models */}
|
||||
{models.length > 0 && (
|
||||
<SectionCard title={`Model & Harga (${models.length})`} accent={accent}>
|
||||
<div className="space-y-3">
|
||||
{models.map((m: {
|
||||
name?: string; sku?: string; price?: number; currency?: string;
|
||||
weight?: number; weightType?: string; length?: number; width?: number; height?: number;
|
||||
dimensionType?: string; isConfigurePromotionPrice?: boolean; promotionPrice?: number;
|
||||
imageId?: string;
|
||||
warehouses?: { id: string; city?: string; province?: string; country?: string; stock?: number }[];
|
||||
}, i: number) => (
|
||||
<div key={i} className={`rounded-lg border p-4 ${accent ? "border-primary/20 bg-white" : "border-slate-100 bg-slate-50"}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{imgUrl(m.imageId) && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imgUrl(m.imageId)!} alt={m.name} className="w-12 h-12 object-cover rounded-lg border border-slate-100" />
|
||||
)}
|
||||
<p className="font-black text-sm text-on-surface">{m.name || `Model ${i + 1}`}</p>
|
||||
</div>
|
||||
<Row label="SKU" value={m.sku} />
|
||||
<Row label="Harga" value={m.price ? `${m.currency || "IDR"} ${Number(m.price).toLocaleString("id-ID")}` : undefined} />
|
||||
<Row label="Berat" value={m.weight ? `${m.weight} ${m.weightType || ""}` : undefined} />
|
||||
<Row label="Dimensi" value={[m.length, m.width, m.height].filter(Boolean).join(" × ") ? `${[m.length, m.width, m.height].filter(Boolean).join(" × ")} ${m.dimensionType || ""}` : undefined} />
|
||||
{m.isConfigurePromotionPrice && <Row label="Harga Promo" value={m.promotionPrice ? `${m.currency || "IDR"} ${Number(m.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
|
||||
{Array.isArray(m.warehouses) && m.warehouses.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
|
||||
{m.warehouses.map((w) => (
|
||||
<div key={w.id} className="flex justify-between text-sm py-1">
|
||||
<span className="text-slate-500">{[w.city, w.province].filter(Boolean).join(", ")}</span>
|
||||
<span className="font-black">{w.stock ?? 0} unit</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Compliance */}
|
||||
{product.complianceInformation && (
|
||||
<SectionCard title="Compliance" accent={accent}>
|
||||
<Row label="Negara Asal" value={product.complianceInformation.countryOfOrigin} />
|
||||
<Row label="Safety Warning" value={product.complianceInformation.safetyWarning} />
|
||||
<Row label="Dangerous Goods" value={product.complianceInformation.isDangerousGoodRegulation} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Warranty */}
|
||||
{product.warrantyInformation && (
|
||||
<SectionCard title="Garansi" accent={accent}>
|
||||
<Row label="Tipe" value={product.warrantyInformation.type} />
|
||||
<Row label="Durasi" value={product.warrantyInformation.duration ? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType}` : undefined} />
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminReviewDetailPage() {
|
||||
const params = useParams<{ productId: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [product, setProduct] = useState<any>(null); // updated (review)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [oldProduct, setOldProduct] = useState<any>(null); // original (compare)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
const [acting, setActing] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [actionSuccess, setActionSuccess] = useState("");
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.productId) return;
|
||||
setLoading(true);
|
||||
|
||||
// Always fetch updated (review) product
|
||||
const reviewFetch = fetch(`/api/admin/review/${params.productId}`, {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
}).then((r) => r.json());
|
||||
|
||||
// Fetch compare (old vs new) — used when isNew=false
|
||||
const compareFetch = fetch(`/api/admin/review/${params.productId}/compare`, {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
}).then((r) => r.json()).catch(() => null);
|
||||
|
||||
Promise.all([reviewFetch, compareFetch])
|
||||
.then(([reviewData, compareData]) => {
|
||||
if (!reviewData?.data) throw new Error(reviewData?.responseDesc || "Data tidak ditemukan");
|
||||
const updated = reviewData.data;
|
||||
setProduct(updated);
|
||||
|
||||
// If isNew=false, extract old product from compare response
|
||||
if (!updated.isNew) {
|
||||
// compare response may have data.original or data.currentProduct etc.
|
||||
const old =
|
||||
compareData?.data?.original ||
|
||||
compareData?.data?.currentProduct ||
|
||||
compareData?.data?.oldProduct ||
|
||||
compareData?.original ||
|
||||
compareData?.currentProduct ||
|
||||
null;
|
||||
setOldProduct(old);
|
||||
}
|
||||
})
|
||||
.catch((e) => setLoadError(e.message || "Gagal memuat data"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.productId]);
|
||||
|
||||
const isComparison = product && !product.isNew;
|
||||
|
||||
async function submitReview(action: "accept" | "reject", reason?: string) {
|
||||
setActing(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/review/${params.productId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-auth-token": getToken() },
|
||||
body: JSON.stringify({ action, isNew: product?.isNew ?? true, reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setActionError(data?.responseDesc || data?.error || "Gagal memproses review");
|
||||
return;
|
||||
}
|
||||
setActionSuccess(action === "accept" ? "Update produk berhasil disetujui!" : "Update produk berhasil ditolak!");
|
||||
setTimeout(() => router.push("/admin/review"), 1800);
|
||||
} catch {
|
||||
setActionError("Gagal terhubung ke server");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading / error states ──────────────────────────────────────────────
|
||||
|
||||
if (loading) return (
|
||||
<div className="m-6 flex items-center justify-center py-32">
|
||||
<span className="material-symbols-outlined text-4xl text-slate-300 animate-spin">progress_activity</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loadError || !product) return (
|
||||
<div className="m-6 flex flex-col items-center justify-center py-32 gap-4">
|
||||
<span className="material-symbols-outlined text-4xl text-error">error</span>
|
||||
<p className="text-sm font-semibold text-error">{loadError || "Data tidak ditemukan"}</p>
|
||||
<button onClick={() => router.back()} className="text-sm font-bold text-primary hover:underline">Kembali</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Reject modal ────────────────────────────────────────────────────────
|
||||
|
||||
const rejectModal = showRejectModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="w-10 h-10 rounded-full bg-error-container flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-error">block</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-on-surface">{isComparison ? "Tolak Update" : "Tolak Produk"}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4">Berikan alasan penolakan agar seller dapat memperbaiki.</p>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Tuliskan alasan penolakan..."
|
||||
rows={4}
|
||||
className="w-full bg-surface-container-low border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-error/20 focus:outline-none resize-none"
|
||||
/>
|
||||
{actionError && <p className="text-xs text-error font-semibold mt-2">{actionError}</p>}
|
||||
<div className="flex gap-3 justify-end mt-5">
|
||||
<button
|
||||
onClick={() => { setShowRejectModal(false); setActionError(""); }}
|
||||
disabled={acting}
|
||||
className="px-5 py-2.5 rounded-xl border border-outline-variant/30 text-sm font-black text-on-surface hover:bg-surface-container-low transition-colors disabled:opacity-40"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitReview("reject", rejectReason)}
|
||||
disabled={acting || !rejectReason.trim()}
|
||||
className="px-5 py-2.5 rounded-xl bg-error text-white text-sm font-black hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{acting ? "Memproses..." : isComparison ? "Tolak Update" : "Tolak Produk"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{rejectModal}
|
||||
|
||||
<div className="m-6 space-y-6 pb-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<nav className="flex mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400 gap-2 items-center">
|
||||
<button onClick={() => router.push("/admin/review")} className="hover:text-primary transition-colors">Reviews</button>
|
||||
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
|
||||
<span className="text-primary">{isComparison ? "Review Update" : "Review Produk Baru"}</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-black tracking-tight text-on-surface">{product.name}</h1>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest border-l-2 ${isComparison ? "bg-secondary-fixed text-on-secondary-fixed-variant border-secondary" : "bg-amber-50 text-amber-700 border-amber-500"}`}>
|
||||
{isComparison ? "Update Review" : product.state}
|
||||
</span>
|
||||
{product.seller?.name && (
|
||||
<span className="text-sm text-slate-500">by <span className="font-semibold text-on-surface">{product.seller.name}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => router.back()} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-base">arrow_back</span>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comparison notice */}
|
||||
{isComparison && (
|
||||
<div className="p-4 bg-secondary-fixed/50 rounded-xl flex items-center gap-3 text-sm font-semibold text-on-secondary-fixed-variant">
|
||||
<span className="material-symbols-outlined text-secondary">compare_arrows</span>
|
||||
Bandingkan perubahan yang diajukan seller (kiri) dengan data produk saat ini (kanan).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content — 1 column (isNew) or 2 columns (update) */}
|
||||
{isComparison ? (
|
||||
<div className="flex gap-6 items-start">
|
||||
<ProductColumn product={product} label="Versi Terbaru (Diajukan)" accent />
|
||||
<ProductColumn product={oldProduct} label="Versi Saat Ini (Live)" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ProductColumn product={product} label="Produk Baru" accent />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seller card (single, below both columns) */}
|
||||
{product.seller && (
|
||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-5">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.18em] text-slate-400 mb-4 pb-3 border-b border-slate-100">Seller</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
{imgUrl(product.seller.imageId) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imgUrl(product.seller.imageId)!} alt={product.seller.name} className="w-12 h-12 rounded-full object-cover border border-slate-100" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-primary">storefront</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-black text-on-surface">{product.seller.name}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">ID: {product.seller.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex flex-col items-end gap-2 pt-2">
|
||||
{actionSuccess && (
|
||||
<div className="w-full p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
{actionSuccess} Mengalihkan...
|
||||
</div>
|
||||
)}
|
||||
{actionError && !showRejectModal && (
|
||||
<div className="w-full p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
{acting && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
Memproses review...
|
||||
</p>
|
||||
)}
|
||||
{!actionSuccess && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => { setShowRejectModal(true); setActionError(""); }}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">block</span>
|
||||
{isComparison ? "Reject Update" : "Reject Product"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitReview("accept")}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">check_circle</span>
|
||||
{acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
278
src/app/admin/review/page.tsx
Normal file
278
src/app/admin/review/page.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface ReviewRow {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
market: string | null;
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
totalStock: number | null;
|
||||
rejectReason: string | null;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function formatPrice(min: number | null, max: number | null) {
|
||||
if (min == null && max == null) return "—";
|
||||
if (min === max || max == null) return (min ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 });
|
||||
return `${(min ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })} – ${max.toLocaleString("en-US", { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function marketBadge(market: string | null) {
|
||||
if (!market) return "bg-slate-100 text-slate-500 border-l-2 border-slate-400";
|
||||
const m = market.toLowerCase();
|
||||
if (m === "international") return "bg-purple-50 text-purple-700 border-l-2 border-purple-700";
|
||||
if (m === "local" || m === "domestic") return "bg-primary-fixed text-on-primary-fixed-variant border-l-2 border-primary";
|
||||
return "bg-secondary-fixed text-on-secondary-fixed-variant border-l-2 border-secondary";
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState<ReviewRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalItem, setTotalItem] = useState(0);
|
||||
const [totalPage, setTotalPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/review?page=${page}&size=${pageSize}`, {
|
||||
headers: { "x-auth-token": getToken() },
|
||||
});
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data?.rows) ? data.rows : []);
|
||||
setTotalItem(data?.totalItem ?? 0);
|
||||
setTotalPage(data?.totalPage ?? 1);
|
||||
} catch {
|
||||
setError("Gagal memuat data review");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [page]);
|
||||
|
||||
const startEntry = page * pageSize + 1;
|
||||
const endEntry = Math.min((page + 1) * pageSize, totalItem);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between mb-10">
|
||||
<div>
|
||||
<nav className="flex mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400 gap-2 items-center">
|
||||
<span>Catalog</span>
|
||||
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
|
||||
<span className="text-primary">Reviews</span>
|
||||
</nav>
|
||||
<h2 className="text-4xl font-extrabold text-on-surface tracking-tight leading-none">Review Pipeline</h2>
|
||||
<p className="mt-2 text-slate-500 font-medium">Monitoring quality control for pending inventory submissions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bento */}
|
||||
<div className="grid grid-cols-12 gap-6 mb-8">
|
||||
<div className="col-span-12 lg:col-span-8 bg-surface-container-low p-8 rounded-xl relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<p className="text-xs font-black uppercase tracking-[0.2em] text-primary mb-1">Queue Status</p>
|
||||
<h3 className="text-5xl font-black text-on-surface mb-4 tracking-tighter">
|
||||
{loading ? "—" : `${totalItem} Pending Review`}
|
||||
</h3>
|
||||
<div className="flex gap-8 mt-6">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase mb-1">SLA Compliance</p>
|
||||
<p className="text-2xl font-black text-on-surface tracking-tight">98.4%</p>
|
||||
</div>
|
||||
<div className="w-px bg-slate-300" />
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase mb-1">Avg. Response</p>
|
||||
<p className="text-2xl font-black text-on-surface tracking-tight">2.4h</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 top-0 h-full w-1/3 bg-primary/5 -skew-x-12 translate-x-12" />
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-4 bg-slate-900 p-8 rounded-xl text-white flex flex-col justify-between">
|
||||
<div>
|
||||
<span className="material-symbols-outlined text-primary mb-4 text-4xl block">priority_high</span>
|
||||
<h4 className="text-xl font-bold leading-tight">High Priority Notifications</h4>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm mt-4">
|
||||
System detected {loading ? "—" : totalItem} {totalItem === 1 ? "entry" : "entries"} requiring manual compliance validation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white shadow-xl shadow-slate-200/50 rounded-xl overflow-hidden">
|
||||
{/* Table header bar */}
|
||||
<div className="p-6 flex justify-between items-center bg-slate-50/50 border-b border-slate-100">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center gap-2 bg-white px-4 py-2 border-b-2 border-slate-200 text-xs font-bold text-slate-600 rounded-t-lg">
|
||||
<span className="material-symbols-outlined text-sm">filter_list</span>
|
||||
All Markets
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Awaiting action</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block animate-spin">progress_activity</span>
|
||||
<p className="text-sm font-medium">Memuat data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-16 text-center text-error">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">error</span>
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-400">
|
||||
<span className="material-symbols-outlined text-4xl mb-4 block">rate_review</span>
|
||||
<p className="text-sm font-medium">Tidak ada produk yang menunggu review</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-100">
|
||||
{["Product Name", "Market", "Price Range", "Total Stock", "Actions"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-8 py-5 text-[11px] font-black uppercase tracking-widest text-slate-500 ${h === "Total Stock" ? "text-center" : h === "Actions" ? "text-right" : ""}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{rows.map((item) => (
|
||||
<tr key={item.id} className="group hover:bg-surface-container-low transition-colors duration-200">
|
||||
{/* Product */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-100 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{item.image ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={item.image.startsWith("http") ? item.image : `${API_BASE}/api/v1.0/file/image/${item.image}`}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover mix-blend-multiply opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-slate-300">inventory_2</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-extrabold text-on-surface group-hover:text-primary transition-colors">{item.name}</p>
|
||||
{item.rejectReason && (
|
||||
<p className="text-[10px] text-error font-medium mt-0.5">Reject reason: {item.rejectReason}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Market */}
|
||||
<td className="px-8 py-6">
|
||||
<span className={`px-3 py-1 text-[10px] font-black uppercase tracking-wider ${marketBadge(item.market)}`}>
|
||||
{item.market || "—"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Price */}
|
||||
<td className="px-8 py-6">
|
||||
<div className="font-bold text-on-surface">{formatPrice(item.minPrice, item.maxPrice)}</div>
|
||||
<div className="text-[10px] text-slate-400">USD Equivalent</div>
|
||||
</td>
|
||||
|
||||
{/* Stock */}
|
||||
<td className="px-8 py-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-slate-100 text-on-surface font-bold text-sm">
|
||||
{item.totalStock ?? 0}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-8 py-6 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="p-2 text-slate-400 hover:text-on-surface transition-colors rounded-lg hover:bg-slate-100" title="View">
|
||||
<span className="material-symbols-outlined">visibility</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("reviewProductCache", JSON.stringify(item));
|
||||
router.push(`/admin/review/${item.id}`);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-container text-white text-[10px] font-black uppercase tracking-widest rounded-xl hover:opacity-90 transition-all active:scale-95 shadow-sm shadow-primary/20"
|
||||
>
|
||||
Review
|
||||
<span className="material-symbols-outlined text-xs">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-8 py-5 flex items-center justify-between bg-slate-50/50 border-t border-slate-100">
|
||||
<div className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
Total Item: <span className="text-on-surface">{totalItem}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="w-8 h-8 flex items-center justify-center text-slate-400 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPage(i)}
|
||||
className={`w-8 h-8 flex items-center justify-center font-black text-xs rounded-lg transition-all ${
|
||||
i === page
|
||||
? "bg-primary text-white shadow-md shadow-primary/20"
|
||||
: "text-slate-500 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPage - 1, p + 1))}
|
||||
disabled={page >= totalPage - 1}
|
||||
className="w-8 h-8 flex items-center justify-center text-slate-400 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] font-medium text-slate-400">
|
||||
{totalItem > 0 ? `Showing ${startEntry} to ${endEntry} of ${totalItem} entries` : "No entries"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
src/app/api/admin/news/[newsId]/route.ts
Normal file
26
src/app/api/admin/news/[newsId]/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
_context: { params: Promise<{ newsId: string }> }
|
||||
) {
|
||||
return NextResponse.json({ responseDesc: "Not supported" }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ newsId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { newsId } = await context.params;
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/newsarticles/${newsId}`, {
|
||||
method: "PUT",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
29
src/app/api/admin/news/route.ts
Normal file
29
src/app/api/admin/news/route.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = parseInt(searchParams.get("page") || "0", 10) + 1; // API is 1-indexed
|
||||
const size = searchParams.get("size") || "20";
|
||||
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/v1.0/newsarticles?page=${page}&size=${size}`,
|
||||
{ headers: makeHeaders(token), cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/newsarticles`, {
|
||||
method: "POST",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
27
src/app/api/admin/places/[placeId]/route.ts
Normal file
27
src/app/api/admin/places/[placeId]/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
_context: { params: Promise<{ placeId: string }> }
|
||||
) {
|
||||
return NextResponse.json({ responseDesc: "Not supported" }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ placeId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { placeId } = await context.params;
|
||||
const body = await req.json();
|
||||
|
||||
// Postman collection shows Update Location uses /addresses/{id}
|
||||
const res = await fetch(`${API_URL}/api/v1.0/addresses/${placeId}`, {
|
||||
method: "PUT",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
29
src/app/api/admin/places/route.ts
Normal file
29
src/app/api/admin/places/route.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = parseInt(searchParams.get("page") || "0", 10) + 1; // API is 1-indexed
|
||||
const size = searchParams.get("size") || "20";
|
||||
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/v1.0/locations?page=${page}&size=${size}`,
|
||||
{ headers: makeHeaders(token), cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/locations`, {
|
||||
method: "POST",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
17
src/app/api/admin/review/[productId]/compare/route.ts
Normal file
17
src/app/api/admin/review/[productId]/compare/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ productId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { productId } = await context.params;
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/product/compare/${productId}`, {
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
54
src/app/api/admin/review/[productId]/route.ts
Normal file
54
src/app/api/admin/review/[productId]/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ productId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { productId } = await context.params;
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/product/review/${productId}`, {
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
// action: "accept" | "reject"
|
||||
// isNew: boolean — determines which backend endpoint to call
|
||||
// body for reject: { reason: string }
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ productId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { productId } = await context.params;
|
||||
const { action, isNew, reason } = await req.json();
|
||||
|
||||
let url: string;
|
||||
let method = "POST";
|
||||
let body: Record<string, string> | undefined;
|
||||
|
||||
if (action === "accept") {
|
||||
// isNew=true: POST /product/accept/{id}
|
||||
// isNew=false: PUT /product/accept/{id}
|
||||
url = `${API_URL}/api/v1.0/product/accept/${productId}`;
|
||||
method = isNew ? "POST" : "PUT";
|
||||
} else {
|
||||
// isNew=true: POST /product/reject/{id}
|
||||
// isNew=false: PUT /product/reject/{id}
|
||||
url = `${API_URL}/api/v1.0/product/reject/${productId}`;
|
||||
method = isNew ? "POST" : "PUT";
|
||||
if (reason) body = { reason };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: makeHeaders(token),
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
16
src/app/api/admin/review/route.ts
Normal file
16
src/app/api/admin/review/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = parseInt(searchParams.get("page") || "0", 10) + 1; // API is 1-indexed
|
||||
const size = searchParams.get("size") || "20";
|
||||
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/v1.0/product/review?page=${page}&size=${size}`,
|
||||
{ headers: makeHeaders(token), cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
261
src/app/api/auth/finalize-register/route.ts
Normal file
261
src/app/api/auth/finalize-register/route.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import redis from "@/lib/redis";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
type RegisterRole = "seller" | "buyer";
|
||||
|
||||
function normalizeMobileNumber(value: string) {
|
||||
const compact = value.replace(/[^\d+]/g, "");
|
||||
|
||||
if (compact.startsWith("+62")) {
|
||||
return compact; // sudah benar: +62811883536
|
||||
}
|
||||
|
||||
if (compact.startsWith("62")) {
|
||||
return `+${compact}`; // 62811883536 → +62811883536
|
||||
}
|
||||
|
||||
if (compact.startsWith("0")) {
|
||||
return `+62${compact.slice(1)}`; // 0811883536 → +62811883536
|
||||
}
|
||||
|
||||
if (compact.startsWith("8")) {
|
||||
return `+62${compact}`; // 811883536 → +62811883536
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown, fallback: string) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const data = payload as {
|
||||
error?: string;
|
||||
message?: string;
|
||||
responseDesc?: string;
|
||||
responseMessage?: string;
|
||||
data?: { message?: string; responseDesc?: string };
|
||||
};
|
||||
|
||||
return (
|
||||
data.error ||
|
||||
data.message ||
|
||||
data.responseDesc ||
|
||||
data.responseMessage ||
|
||||
data.data?.message ||
|
||||
data.data?.responseDesc ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
async function loginSeller(email: string, password: string) {
|
||||
const credentials = Buffer.from(`${email}:${password}`).toString("base64");
|
||||
const res = await fetch(`${API_URL}/api/v1.0/seller/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...makeHeaders(),
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Origin: "web",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { res, data };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { role, registerData, sellerData } = await req.json();
|
||||
|
||||
const normalizedRole: RegisterRole | null =
|
||||
role === "seller" || role === "buyer" ? role : null;
|
||||
|
||||
if (!normalizedRole || !registerData) {
|
||||
return NextResponse.json(
|
||||
{ error: "Data registrasi tidak lengkap", step: "payload_validation" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, name, mobile, password, otpVerified } = registerData as {
|
||||
email?: string;
|
||||
name?: string;
|
||||
mobile?: string;
|
||||
password?: string;
|
||||
otpVerified?: boolean;
|
||||
};
|
||||
|
||||
if (!email || !name || !mobile || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Data akun belum lengkap", step: "payload_validation" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!otpVerified) {
|
||||
return NextResponse.json(
|
||||
{ error: "OTP belum terverifikasi", step: "otp_gate" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const normalizedMobile = normalizeMobileNumber(mobile);
|
||||
|
||||
const registerPayload = {
|
||||
name: name.trim(),
|
||||
email: normalizedEmail,
|
||||
mobile: normalizedMobile,
|
||||
password,
|
||||
};
|
||||
|
||||
console.info("[finalize-register] Seller register payload", {
|
||||
role: normalizedRole,
|
||||
payload: registerPayload,
|
||||
});
|
||||
|
||||
const registerHeaders = makeHeaders();
|
||||
console.info("[finalize-register] Sending to backend", {
|
||||
url: `${API_URL}/api/v1.0/${normalizedRole}/register`,
|
||||
headers: registerHeaders,
|
||||
body: registerPayload,
|
||||
});
|
||||
|
||||
const registerRes = await fetch(`${API_URL}/api/v1.0/${normalizedRole}/register`, {
|
||||
method: "POST",
|
||||
headers: registerHeaders,
|
||||
body: JSON.stringify(registerPayload),
|
||||
});
|
||||
|
||||
const registerResponse = await registerRes.json().catch(() => ({}));
|
||||
|
||||
if (registerRes.status !== 200 && registerRes.status !== 201) {
|
||||
console.warn("[finalize-register] Backend register rejected request", {
|
||||
role: normalizedRole,
|
||||
email: normalizedEmail,
|
||||
status: registerRes.status,
|
||||
response: registerResponse,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: extractErrorMessage(registerResponse, "Registrasi gagal"),
|
||||
step: "backend_register",
|
||||
details: registerResponse,
|
||||
},
|
||||
{ status: registerRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedRole === "buyer") {
|
||||
await redis.del(`otp:${normalizedEmail}`);
|
||||
return NextResponse.json({
|
||||
message: "Registrasi buyer berhasil",
|
||||
step: "completed",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sellerData) {
|
||||
const { res: loginRes, data: loginData } = await loginSeller(
|
||||
normalizedEmail,
|
||||
password
|
||||
);
|
||||
|
||||
const sessionToken = loginData?.data?.session;
|
||||
|
||||
if (loginRes.status !== 200 || !sessionToken) {
|
||||
console.warn("[finalize-register] Seller login after register failed", {
|
||||
email: normalizedEmail,
|
||||
status: loginRes.status,
|
||||
response: loginData,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: extractErrorMessage(
|
||||
loginData,
|
||||
"Registrasi berhasil, tetapi login seller gagal"
|
||||
),
|
||||
step: "seller_login",
|
||||
details: loginData,
|
||||
},
|
||||
{ status: loginRes.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
await redis.del(`otp:${normalizedEmail}`);
|
||||
return NextResponse.json({
|
||||
message: "Registrasi seller berhasil",
|
||||
step: "seller_registered",
|
||||
token: `Bearer ${sessionToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
const { res: loginRes, data: loginData } = await loginSeller(
|
||||
normalizedEmail,
|
||||
password
|
||||
);
|
||||
|
||||
const sessionToken = loginData?.data?.session;
|
||||
|
||||
if (loginRes.status !== 200 || !sessionToken) {
|
||||
console.warn("[finalize-register] Seller login after register failed", {
|
||||
email: normalizedEmail,
|
||||
status: loginRes.status,
|
||||
response: loginData,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: extractErrorMessage(
|
||||
loginData,
|
||||
"Registrasi berhasil, tetapi login seller gagal"
|
||||
),
|
||||
step: "seller_login",
|
||||
details: loginData,
|
||||
},
|
||||
{ status: loginRes.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const sellerRes = await fetch(`${API_URL}/api/v1.0/seller`, {
|
||||
method: "POST",
|
||||
headers: makeHeaders(`Bearer ${sessionToken}`),
|
||||
body: JSON.stringify(sellerData),
|
||||
});
|
||||
|
||||
const sellerResponse = await sellerRes.json().catch(() => ({}));
|
||||
|
||||
if (sellerRes.status !== 200 && sellerRes.status !== 201) {
|
||||
console.warn("[finalize-register] Seller profile creation failed", {
|
||||
email: normalizedEmail,
|
||||
status: sellerRes.status,
|
||||
response: sellerResponse,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: extractErrorMessage(
|
||||
sellerResponse,
|
||||
"Akun berhasil dibuat, tetapi data bisnis gagal disimpan"
|
||||
),
|
||||
step: "seller_profile",
|
||||
details: sellerResponse,
|
||||
},
|
||||
{ status: sellerRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
await redis.del(`otp:${normalizedEmail}`);
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Registrasi seller berhasil",
|
||||
step: "completed",
|
||||
token: `Bearer ${sessionToken}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[finalize-register] Unexpected error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Terjadi kesalahan pada server", step: "unexpected" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/app/api/auth/login/route.ts
Normal file
71
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
async function tryLogin(
|
||||
role: "seller" | "buyer",
|
||||
email: string,
|
||||
password: string
|
||||
) {
|
||||
const credentials = Buffer.from(`${email}:${password}`).toString("base64");
|
||||
const res = await fetch(`${API_URL}/api/v1.0/${role}/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...makeHeaders(),
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Origin: "web",
|
||||
},
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email dan password wajib diisi" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const sellerRes = await tryLogin("seller", email, password);
|
||||
if (sellerRes.status === 200 && sellerRes.data?.data?.session) {
|
||||
const token = `Bearer ${sellerRes.data.data.session}`;
|
||||
const userType = sellerRes.data.data.userType;
|
||||
|
||||
if (userType === "ADMIN") {
|
||||
return NextResponse.json({ token, role: "admin" });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
token,
|
||||
role: "seller",
|
||||
onboardingRequired: false,
|
||||
});
|
||||
}
|
||||
|
||||
const buyerRes = await tryLogin("buyer", email, password);
|
||||
if (buyerRes.status === 200 && buyerRes.data?.data?.session) {
|
||||
return NextResponse.json({
|
||||
token: `Bearer ${buyerRes.data.data.session}`,
|
||||
role: "buyer",
|
||||
});
|
||||
}
|
||||
|
||||
const NOT_FOUND_CODE = "000011";
|
||||
const isNotFound =
|
||||
sellerRes.status === 404 ||
|
||||
buyerRes.status === 404 ||
|
||||
sellerRes.data?.responseCode === NOT_FOUND_CODE ||
|
||||
buyerRes.data?.responseCode === NOT_FOUND_CODE;
|
||||
|
||||
if (isNotFound) {
|
||||
return NextResponse.json({ error: "ACCOUNT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Email atau password salah" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
28
src/app/api/auth/send-otp/route.ts
Normal file
28
src/app/api/auth/send-otp/route.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import redis from "@/lib/redis";
|
||||
import { sendOTPEmail } from "@/lib/mailer";
|
||||
|
||||
function generateOTP(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { email } = await req.json();
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email wajib diisi" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const otp = generateOTP();
|
||||
const normalizedEmail =
|
||||
typeof email === "string" ? email.trim().toLowerCase() : email;
|
||||
const key = `otp:${normalizedEmail}`;
|
||||
|
||||
await redis.set(key, otp, "EX", 600); // 10 menit
|
||||
await sendOTPEmail(normalizedEmail, otp);
|
||||
|
||||
return NextResponse.json({ message: "OTP berhasil dikirim" });
|
||||
}
|
||||
45
src/app/api/auth/verify-otp/route.ts
Normal file
45
src/app/api/auth/verify-otp/route.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import redis from "@/lib/redis";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { email, otp } = await req.json();
|
||||
|
||||
if (!email || !otp) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email dan OTP wajib diisi", step: "payload_validation" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedEmail =
|
||||
typeof email === "string" ? email.trim().toLowerCase() : email;
|
||||
const key = `otp:${normalizedEmail}`;
|
||||
const storedOTP = await redis.get(key);
|
||||
|
||||
if (!storedOTP) {
|
||||
return NextResponse.json(
|
||||
{ error: "OTP sudah kedaluwarsa, silakan minta ulang", step: "otp_lookup" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (storedOTP !== otp) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kode OTP tidak valid", step: "otp_compare" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "OTP valid",
|
||||
step: "otp_verified",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[verify-otp] Unexpected error", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Terjadi kesalahan pada server", step: "unexpected" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/locations/all/route.ts
Normal file
43
src/app/api/locations/all/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const headers = makeHeaders(token ? `Bearer ${token}` : undefined);
|
||||
|
||||
// 1. Fetch semua provinsi
|
||||
const provincesRes = await fetch(`${API_URL}/api/v1.0/provinces`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const provincesData = await provincesRes.json().catch(() => ({}));
|
||||
|
||||
if (!provincesRes.ok) {
|
||||
return NextResponse.json(provincesData, { status: provincesRes.status });
|
||||
}
|
||||
|
||||
const provinces: { id: string; name: string }[] = Array.isArray(provincesData?.rows)
|
||||
? provincesData.rows
|
||||
: [];
|
||||
|
||||
// 2. Fetch semua kota secara paralel
|
||||
const cityResults = await Promise.all(
|
||||
provinces.map(async (province) => {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/v1.0/cities?provinceId=${province.id}`,
|
||||
{ method: "GET", headers, cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const rows: { id: string; name: string }[] = Array.isArray(data?.rows)
|
||||
? data.rows
|
||||
: [];
|
||||
return rows.map((city) => ({ ...city, provinceId: province.id }));
|
||||
})
|
||||
);
|
||||
|
||||
const cities = cityResults.flat();
|
||||
|
||||
return NextResponse.json({ provinces, cities });
|
||||
}
|
||||
50
src/app/api/locations/cities/route.ts
Normal file
50
src/app/api/locations/cities/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const provinceId = req.nextUrl.searchParams.get("provinceId");
|
||||
|
||||
if (!provinceId) {
|
||||
return NextResponse.json(
|
||||
{ error: "provinceId wajib diisi" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const authHeaders = makeHeaders(token || undefined);
|
||||
|
||||
// Fetch halaman pertama
|
||||
const firstRes = await fetch(
|
||||
`${API_URL}/api/v1.0/cities?provinceId=${provinceId}&page=1&size=999`,
|
||||
{ method: "GET", headers: authHeaders, cache: "no-store" }
|
||||
);
|
||||
|
||||
const firstData = await firstRes.json().catch(() => ({}));
|
||||
|
||||
if (!firstRes.ok) {
|
||||
return NextResponse.json(firstData, { status: firstRes.status });
|
||||
}
|
||||
|
||||
const totalPage: number = firstData?.totalPage ?? 1;
|
||||
let rows: unknown[] = Array.isArray(firstData?.rows) ? firstData.rows : [];
|
||||
|
||||
// Jika ada lebih dari 1 halaman, fetch semua paralel
|
||||
if (totalPage > 1) {
|
||||
const pages = Array.from({ length: totalPage - 1 }, (_, i) => i + 2);
|
||||
const rest = await Promise.all(
|
||||
pages.map((page) =>
|
||||
fetch(
|
||||
`${API_URL}/api/v1.0/cities?provinceId=${provinceId}&page=${page}&size=999`,
|
||||
{ method: "GET", headers: authHeaders, cache: "no-store" }
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((d) => (Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => [])
|
||||
)
|
||||
);
|
||||
rows = rows.concat(rest.flat());
|
||||
}
|
||||
|
||||
return NextResponse.json({ rows, totalItem: rows.length });
|
||||
}
|
||||
41
src/app/api/locations/provinces/route.ts
Normal file
41
src/app/api/locations/provinces/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: import("next/server").NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const authHeaders = makeHeaders(token || undefined);
|
||||
|
||||
const firstRes = await fetch(`${API_URL}/api/v1.0/provinces?page=1&size=999`, {
|
||||
method: "GET",
|
||||
headers: authHeaders,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const firstData = await firstRes.json().catch(() => ({}));
|
||||
|
||||
if (!firstRes.ok) {
|
||||
return NextResponse.json(firstData, { status: firstRes.status });
|
||||
}
|
||||
|
||||
const totalPage: number = firstData?.totalPage ?? 1;
|
||||
let rows: unknown[] = Array.isArray(firstData?.rows) ? firstData.rows : [];
|
||||
|
||||
if (totalPage > 1) {
|
||||
const pages = Array.from({ length: totalPage - 1 }, (_, i) => i + 2);
|
||||
const rest = await Promise.all(
|
||||
pages.map((page) =>
|
||||
fetch(`${API_URL}/api/v1.0/provinces?page=${page}&size=999`, {
|
||||
method: "GET",
|
||||
headers: authHeaders,
|
||||
cache: "no-store",
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => (Array.isArray(d?.rows) ? d.rows : []))
|
||||
.catch(() => [])
|
||||
)
|
||||
);
|
||||
rows = rows.concat(rest.flat());
|
||||
}
|
||||
|
||||
return NextResponse.json({ rows, totalItem: rows.length });
|
||||
}
|
||||
68
src/app/api/products/[productId]/route.ts
Normal file
68
src/app/api/products/[productId]/route.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ productId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { productId } = await context.params;
|
||||
const isDraft = req.nextUrl.searchParams.get("draft") === "1";
|
||||
|
||||
const endpoint = isDraft
|
||||
? `${API_URL}/api/v1.0/seller/draft/product/${productId}`
|
||||
: `${API_URL}/api/v1.0/product/${productId}`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "GET",
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ productId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { productId } = await context.params;
|
||||
const body = await req.json();
|
||||
const isDraft = req.nextUrl.searchParams.get("draft") === "1";
|
||||
|
||||
const endpoint = isDraft
|
||||
? `${API_URL}/api/v1.0/product/draft/${productId}`
|
||||
: `${API_URL}/api/v1.0/product/${productId}`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "PUT",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ productId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { productId } = await context.params;
|
||||
const isDraft = req.nextUrl.searchParams.get("draft") === "1";
|
||||
|
||||
const endpoint = isDraft
|
||||
? `${API_URL}/api/v1.0/product/draft/${productId}`
|
||||
: `${API_URL}/api/v1.0/product/${productId}`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "DELETE",
|
||||
headers: makeHeaders(token),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
20
src/app/api/products/categories/route.ts
Normal file
20
src/app/api/products/categories/route.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = rawToken.startsWith("Bearer ") ? rawToken : rawToken ? `Bearer ${rawToken}` : "";
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = searchParams.get("page") || "1";
|
||||
const size = searchParams.get("size") || "100";
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/categories?page=${page}&size=${size}`, {
|
||||
method: "GET",
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
16
src/app/api/products/create/route.ts
Normal file
16
src/app/api/products/create/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/product`, {
|
||||
method: "POST",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
31
src/app/api/products/route.ts
Normal file
31
src/app/api/products/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const tab = searchParams.get("tab");
|
||||
|
||||
const endpointMap: Record<string, string> = {
|
||||
draft: "/api/v1.0/seller/draft/product",
|
||||
"in-review": "/api/v1.0/product/review",
|
||||
"international-market": "/api/v1.0/seller/international/product",
|
||||
"local-market": "/api/v1.0/seller/local/product",
|
||||
"out-of-stock": "/api/v1.0/seller/outofstock/product",
|
||||
rejected: "/api/v1.0/seller/reject/product",
|
||||
};
|
||||
|
||||
const endpoint = endpointMap[tab || ""] || "/api/v1.0/seller/product";
|
||||
const forwardParams = new URLSearchParams(searchParams.toString());
|
||||
forwardParams.delete("tab");
|
||||
const suffix = forwardParams.toString() ? `?${forwardParams.toString()}` : "";
|
||||
|
||||
const res = await fetch(`${API_URL}${endpoint}${suffix}`, {
|
||||
method: "GET",
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
24
src/app/api/products/subcategories/[categoryId]/route.ts
Normal file
24
src/app/api/products/subcategories/[categoryId]/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ categoryId: string }> }
|
||||
) {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = rawToken.startsWith("Bearer ") ? rawToken : rawToken ? `Bearer ${rawToken}` : "";
|
||||
const { categoryId } = await context.params;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = searchParams.get("page") || "1";
|
||||
const size = searchParams.get("size") || "100";
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/sub/${categoryId}/categories?page=${page}&size=${size}`, {
|
||||
method: "GET",
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
18
src/app/api/products/submit-review/[productId]/route.ts
Normal file
18
src/app/api/products/submit-review/[productId]/route.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ productId: string }> }
|
||||
) {
|
||||
const token = req.headers.get("x-auth-token") || "";
|
||||
const { productId } = await context.params;
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/product/submit-review/${productId}`, {
|
||||
method: "POST",
|
||||
headers: makeHeaders(token),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
20
src/app/api/products/warehouses/route.ts
Normal file
20
src/app/api/products/warehouses/route.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = rawToken.startsWith("Bearer ") ? rawToken : rawToken ? `Bearer ${rawToken}` : "";
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = searchParams.get("page") || "1";
|
||||
const size = searchParams.get("size") || "100";
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/warehouses?page=${page}&size=${size}`, {
|
||||
method: "GET",
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
21
src/app/api/profile/change-password/route.ts
Normal file
21
src/app/api/profile/change-password/route.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
function normalizeBearerToken(raw: string) {
|
||||
if (!raw) return "";
|
||||
return raw.startsWith("Bearer ") ? raw : `Bearer ${raw}`;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/profile/change-password`, {
|
||||
method: "PUT",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
34
src/app/api/seller/profile/route.ts
Normal file
34
src/app/api/seller/profile/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
function normalizeBearerToken(rawToken: string) {
|
||||
if (!rawToken) return "";
|
||||
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/seller/profile`, {
|
||||
method: "GET",
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/seller/store`, {
|
||||
method: "PUT",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
39
src/app/api/seller/route.ts
Normal file
39
src/app/api/seller/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = rawToken.startsWith("Bearer ")
|
||||
? rawToken
|
||||
: rawToken
|
||||
? `Bearer ${rawToken}`
|
||||
: "";
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/seller`, {
|
||||
method: "GET",
|
||||
headers: makeHeaders(token),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = rawToken.startsWith("Bearer ")
|
||||
? rawToken
|
||||
: rawToken
|
||||
? `Bearer ${rawToken}`
|
||||
: "";
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/seller`, {
|
||||
method: "POST",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
22
src/app/api/seller/store/route.ts
Normal file
22
src/app/api/seller/store/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
function normalizeBearerToken(rawToken: string) {
|
||||
if (!rawToken) return "";
|
||||
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = normalizeBearerToken(rawToken);
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/seller/store`, {
|
||||
method: "PUT",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
46
src/app/api/upload/route.ts
Normal file
46
src/app/api/upload/route.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = rawToken.startsWith("Bearer ")
|
||||
? rawToken
|
||||
: rawToken
|
||||
? `Bearer ${rawToken}`
|
||||
: "";
|
||||
const formData = await req.formData();
|
||||
|
||||
const headers = makeHeaders(token);
|
||||
delete headers["Content-Type"];
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/file/upload`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const raw = await res.text();
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = raw ? JSON.parse(raw) : {};
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Upload endpoint returned a non-JSON response",
|
||||
details: raw.slice(0, 500),
|
||||
},
|
||||
{ status: res.status || 502 }
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Upload proxy failed",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/app/api/warehouses/[warehouseId]/route.ts
Normal file
39
src/app/api/warehouses/[warehouseId]/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
function normalizeBearerToken(rawToken: string) {
|
||||
if (!rawToken) return "";
|
||||
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ warehouseId: string }> }
|
||||
) {
|
||||
const { warehouseId } = await context.params;
|
||||
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/warehouses/${warehouseId}`, {
|
||||
method: "PUT",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ warehouseId: string }> }
|
||||
) {
|
||||
const { warehouseId } = await context.params;
|
||||
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/warehouses/${warehouseId}`, {
|
||||
method: "DELETE",
|
||||
headers: makeHeaders(token),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
22
src/app/api/warehouses/route.ts
Normal file
22
src/app/api/warehouses/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_URL, makeHeaders } from "@/lib/api";
|
||||
|
||||
function normalizeBearerToken(rawToken: string) {
|
||||
if (!rawToken) return "";
|
||||
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawToken = req.headers.get("x-auth-token") || "";
|
||||
const token = normalizeBearerToken(rawToken);
|
||||
const body = await req.json();
|
||||
|
||||
const res = await fetch(`${API_URL}/api/v1.0/warehouses`, {
|
||||
method: "POST",
|
||||
headers: makeHeaders(token),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 362 B |
@ -1,26 +1,101 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
@theme {
|
||||
/* Primary */
|
||||
--color-primary: #b7131a;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-primary-container: #db322f;
|
||||
--color-on-primary-container: #fffbff;
|
||||
--color-primary-fixed: #ffdad6;
|
||||
--color-primary-fixed-dim: #ffb4ac;
|
||||
--color-on-primary-fixed: #410002;
|
||||
--color-on-primary-fixed-variant: #93000d;
|
||||
|
||||
/* Secondary */
|
||||
--color-secondary: #4c56af;
|
||||
--color-on-secondary: #ffffff;
|
||||
--color-secondary-container: #959efd;
|
||||
--color-on-secondary-container: #27308a;
|
||||
--color-secondary-fixed: #e0e0ff;
|
||||
--color-secondary-fixed-dim: #bdc2ff;
|
||||
--color-on-secondary-fixed: #000767;
|
||||
--color-on-secondary-fixed-variant: #343d96;
|
||||
|
||||
/* Tertiary */
|
||||
--color-tertiary: #8237b2;
|
||||
--color-on-tertiary: #ffffff;
|
||||
--color-tertiary-container: #9d52cd;
|
||||
--color-on-tertiary-container: #fffbff;
|
||||
--color-tertiary-fixed: #f4d9ff;
|
||||
--color-tertiary-fixed-dim: #e4b5ff;
|
||||
--color-on-tertiary-fixed: #2f004b;
|
||||
--color-on-tertiary-fixed-variant: #6a1b9a;
|
||||
|
||||
/* Error */
|
||||
--color-error: #ba1a1a;
|
||||
--color-on-error: #ffffff;
|
||||
--color-error-container: #ffdad6;
|
||||
--color-on-error-container: #93000a;
|
||||
|
||||
/* Surface */
|
||||
--color-background: #f8f9fa;
|
||||
--color-on-background: #191c1d;
|
||||
--color-surface: #f8f9fa;
|
||||
--color-on-surface: #191c1d;
|
||||
--color-surface-variant: #e1e3e4;
|
||||
--color-on-surface-variant: #5b403d;
|
||||
--color-surface-bright: #f8f9fa;
|
||||
--color-surface-dim: #d9dadb;
|
||||
--color-surface-tint: #bb171c;
|
||||
--color-surface-container: #edeeef;
|
||||
--color-surface-container-low: #f3f4f5;
|
||||
--color-surface-container-high: #e7e8e9;
|
||||
--color-surface-container-highest: #e1e3e4;
|
||||
--color-surface-container-lowest: #ffffff;
|
||||
|
||||
/* Inverse */
|
||||
--color-inverse-surface: #2e3132;
|
||||
--color-inverse-primary: #ffb4ac;
|
||||
--color-inverse-on-surface: #f0f1f2;
|
||||
|
||||
/* Outline */
|
||||
--color-outline: #906f6c;
|
||||
--color-outline-variant: #e4beb9;
|
||||
|
||||
/* Border radius */
|
||||
--radius: 0.125rem;
|
||||
--radius-lg: 0.25rem;
|
||||
--radius-xl: 0.5rem;
|
||||
--radius-full: 0.75rem;
|
||||
|
||||
/* Fonts */
|
||||
--font-headline: var(--font-manrope), "Manrope", sans-serif;
|
||||
--font-body: var(--font-inter), "Inter", sans-serif;
|
||||
--font-label: var(--font-inter), "Inter", sans-serif;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
font-family: 'Material Symbols Outlined';
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
.bg-primary-editorial {
|
||||
background: linear-gradient(135deg, #b7131a 0%, #db322f 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
.signature-cta-gradient {
|
||||
background: linear-gradient(135deg, #b7131a 0%, #db322f 100%);
|
||||
}
|
||||
|
||||
.editorial-gradient {
|
||||
background: linear-gradient(135deg, #b7131a 0%, #db322f 100%);
|
||||
}
|
||||
|
||||
.magazine-shadow {
|
||||
box-shadow: 0 20px 40px rgba(25, 28, 29, 0.06);
|
||||
}
|
||||
|
||||
.otp-input:focus {
|
||||
box-shadow: 0 4px 0 0 #b7131a;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
5
src/app/icon.svg
Normal file
5
src/app/icon.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="white" rx="8"/>
|
||||
<text x="50" y="58" font-family="Arial Black, Arial" font-weight="900" font-size="46" fill="#b7131a" text-anchor="middle" letter-spacing="-1">INA</text>
|
||||
<text x="50" y="82" font-family="Georgia, Times New Roman, serif" font-weight="700" font-size="16" fill="#1a237e" text-anchor="middle" letter-spacing="1">TRADING</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@ -1,20 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter, Manrope } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { I18nProvider } from "@/lib/i18n-context";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const manrope = Manrope({
|
||||
variable: "--font-manrope",
|
||||
subsets: ["latin"],
|
||||
weight: ["600", "700", "800"],
|
||||
});
|
||||
|
||||
const metadataBase = new URL(
|
||||
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
||||
);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
metadataBase,
|
||||
title: "Ina Trading",
|
||||
description: "Platform perdagangan internasional untuk UMKM Indonesia",
|
||||
applicationName: "Ina Trading",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||
],
|
||||
shortcut: "/favicon.svg",
|
||||
apple: "/icon.svg",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Ina Trading",
|
||||
description: "Platform perdagangan internasional untuk UMKM Indonesia",
|
||||
images: ["/icon.svg"],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "Ina Trading",
|
||||
description: "Platform perdagangan internasional untuk UMKM Indonesia",
|
||||
images: ["/icon.svg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@ -23,11 +51,17 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="en" className={`${inter.variable} ${manrope.variable}`}>
|
||||
<head>
|
||||
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=optional"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-background text-on-surface font-body antialiased">
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
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={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] 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"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
197
src/components/app-icon.tsx
Normal file
197
src/components/app-icon.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
type AppIconName =
|
||||
| "dashboard"
|
||||
| "inventory_2"
|
||||
| "inventory"
|
||||
| "warehouse"
|
||||
| "shopping_cart"
|
||||
| "receipt"
|
||||
| "groups"
|
||||
| "campaign"
|
||||
| "payments"
|
||||
| "assessment"
|
||||
| "analytics"
|
||||
| "storefront"
|
||||
| "reviews"
|
||||
| "settings"
|
||||
| "help"
|
||||
| "logout"
|
||||
| "search"
|
||||
| "translate"
|
||||
| "notifications"
|
||||
| "chat"
|
||||
| "person";
|
||||
|
||||
export function AppIcon({
|
||||
name,
|
||||
className = "h-5 w-5",
|
||||
}: {
|
||||
name: AppIconName;
|
||||
className?: string;
|
||||
}) {
|
||||
const common = {
|
||||
className,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "dashboard":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="3" y="3" width="8" height="8" rx="1.5" />
|
||||
<rect x="13" y="3" width="8" height="5" rx="1.5" />
|
||||
<rect x="13" y="10" width="8" height="11" rx="1.5" />
|
||||
<rect x="3" y="13" width="8" height="8" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
case "inventory_2":
|
||||
case "inventory":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 8.5h16v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
|
||||
<path d="M9 11h6" />
|
||||
<path d="M5 8.5 7 4h10l2 4.5" />
|
||||
</svg>
|
||||
);
|
||||
case "warehouse":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M3 10.5 12 4l9 6.5" />
|
||||
<path d="M5 10v9h14v-9" />
|
||||
<path d="M9 19v-5h6v5" />
|
||||
</svg>
|
||||
);
|
||||
case "shopping_cart":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="9" cy="20" r="1.5" />
|
||||
<circle cx="17" cy="20" r="1.5" />
|
||||
<path d="M3 4h2l2.4 10.2a1 1 0 0 0 1 .8h8.8a1 1 0 0 0 1-.8L20 7H7" />
|
||||
</svg>
|
||||
);
|
||||
case "receipt":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 3h10a1 1 0 0 1 1 1v16l-3-2-3 2-3-2-3 2V4a1 1 0 0 1 1-1Z" />
|
||||
<path d="M9 8h6M9 12h6" />
|
||||
</svg>
|
||||
);
|
||||
case "groups":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="9" cy="8" r="3" />
|
||||
<path d="M3.5 18a5.5 5.5 0 0 1 11 0" />
|
||||
<circle cx="17" cy="9" r="2.5" />
|
||||
<path d="M15 18a4.5 4.5 0 0 1 5.5-4.4" />
|
||||
</svg>
|
||||
);
|
||||
case "campaign":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 11v2" />
|
||||
<path d="M6 9h3l7-3v12l-7-3H6z" />
|
||||
<path d="M6 14v4" />
|
||||
</svg>
|
||||
);
|
||||
case "payments":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M7 14h3" />
|
||||
</svg>
|
||||
);
|
||||
case "assessment":
|
||||
case "analytics":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M5 19V9" />
|
||||
<path d="M12 19V5" />
|
||||
<path d="M19 19v-7" />
|
||||
</svg>
|
||||
);
|
||||
case "storefront":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 10h16" />
|
||||
<path d="M5 10V7l1.5-3h11L19 7v3" />
|
||||
<path d="M6 10v10h12V10" />
|
||||
<path d="M10 20v-6h4v6" />
|
||||
</svg>
|
||||
);
|
||||
case "reviews":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 5h16v10H8l-4 4z" />
|
||||
<path d="m12 8 .8 1.6 1.7.2-1.2 1.2.3 1.7-1.6-.8-1.6.8.3-1.7-1.2-1.2 1.7-.2z" />
|
||||
</svg>
|
||||
);
|
||||
case "settings":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 1 1.5h0a1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.2a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</svg>
|
||||
);
|
||||
case "help":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.5 9a2.5 2.5 0 1 1 3.6 2.2c-.8.4-1.1.9-1.1 1.8" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
);
|
||||
case "logout":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M10 17l5-5-5-5" />
|
||||
<path d="M15 12H4" />
|
||||
<path d="M20 4v16" />
|
||||
</svg>
|
||||
);
|
||||
case "search":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="11" cy="11" r="6" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
);
|
||||
case "translate":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M5 7h8" />
|
||||
<path d="M9 5v2a9 9 0 0 1-4 7" />
|
||||
<path d="M7 11a14 14 0 0 0 4 4" />
|
||||
<path d="M13 19l4-10 4 10" />
|
||||
<path d="M14.5 15h5" />
|
||||
</svg>
|
||||
);
|
||||
case "notifications":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M6 8a6 6 0 1 1 12 0v4l1.5 2.5H4.5L6 12z" />
|
||||
<path d="M10 18a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
);
|
||||
case "chat":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 5h16v10H8l-4 4z" />
|
||||
</svg>
|
||||
);
|
||||
case "person":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="12" cy="8" r="3.5" />
|
||||
<path d="M5 20a7 7 0 0 1 14 0" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
22
src/components/language-toggle.tsx
Normal file
22
src/components/language-toggle.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useLanguage } from "@/lib/i18n-context";
|
||||
|
||||
interface LanguageToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LanguageToggle({ className = "" }: LanguageToggleProps) {
|
||||
const { lang, toggleLang } = useLanguage();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
title={lang === "id" ? "Switch to English" : "Ganti ke Indonesia"}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-outline-variant/40 bg-surface-container-low hover:bg-surface-container hover:border-primary/40 transition-all text-xs font-bold text-on-surface-variant hover:text-primary active:scale-95 ${className}`}
|
||||
>
|
||||
<span className="text-base leading-none">{lang === "id" ? "🇮🇩" : "🇬🇧"}</span>
|
||||
<span className="uppercase tracking-wider">{lang === "id" ? "ID" : "EN"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
59
src/components/product-submenu-nav.tsx
Normal file
59
src/components/product-submenu-nav.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
const productSubmenu = [
|
||||
{ label: "All Product", href: "/products" },
|
||||
{ label: "Draft", href: "/products?tab=draft" },
|
||||
{ label: "In Review", href: "/products?tab=in-review" },
|
||||
{ label: "International Market", href: "/products?tab=international-market" },
|
||||
{ label: "Local Market", href: "/products?tab=local-market" },
|
||||
{ label: "Out Of Stock", href: "/products?tab=out-of-stock" },
|
||||
{ label: "Rejected", href: "/products?tab=rejected" },
|
||||
];
|
||||
|
||||
function ProductSubmenuNavInner() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const currentTab = searchParams.get("tab") ?? "";
|
||||
|
||||
if (pathname !== "/products" && !pathname.startsWith("/products/")) return null;
|
||||
|
||||
return (
|
||||
<div className="ml-10 mt-1 space-y-0.5">
|
||||
{productSubmenu.map((submenu) => {
|
||||
const submenuTab = new URLSearchParams(
|
||||
submenu.href.split("?")[1] || ""
|
||||
).get("tab") ?? "";
|
||||
const isAllProduct = submenu.href === "/products";
|
||||
const isSubmenuActive =
|
||||
pathname === "/products" &&
|
||||
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={submenu.href}
|
||||
href={submenu.href}
|
||||
className={`flex items-center py-2 pl-3 text-sm font-semibold transition-all rounded-r-xl ${
|
||||
isSubmenuActive
|
||||
? "bg-white text-primary border-l-4 border-primary shadow-sm rounded-l-none"
|
||||
: "text-on-surface-variant hover:text-primary hover:bg-surface-container/60 border-l-2 border-surface-container"
|
||||
}`}
|
||||
>
|
||||
{submenu.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductSubmenuNav() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ProductSubmenuNavInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
137
src/components/upload-field.tsx
Normal file
137
src/components/upload-field.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface UploadFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
accept?: string;
|
||||
helperText?: string;
|
||||
onUploaded: (fileId: string) => void;
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: string) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const lastSegment = url.pathname.split("/").filter(Boolean).pop();
|
||||
return lastSegment || url.hostname;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
export function UploadField({
|
||||
label,
|
||||
value,
|
||||
accept,
|
||||
helperText,
|
||||
onUploaded,
|
||||
}: UploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const displayValue = formatDisplayValue(value);
|
||||
|
||||
async function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "x-auth-token": getToken() },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.responseDesc || data?.error || "Upload gagal");
|
||||
}
|
||||
|
||||
const fileId = data?.data?.id || data?.data?.fileId || data?.fileId || "";
|
||||
|
||||
if (!fileId) {
|
||||
throw new Error("File id tidak ditemukan pada response upload");
|
||||
}
|
||||
|
||||
setFileName(file.name);
|
||||
onUploaded(fileId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload gagal");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-low p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-on-surface">
|
||||
{value ? "Uploaded" : "No file uploaded"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-on-surface-variant" title={fileName || value}>
|
||||
{fileName ||
|
||||
(value
|
||||
? `File: ${displayValue}`
|
||||
: helperText || "Select a file")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-full rounded-xl bg-primary px-4 py-3 text-center text-xs font-black uppercase tracking-[0.14em] text-white disabled:opacity-60"
|
||||
>
|
||||
{uploading ? "Uploading..." : value ? "Replace" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-3 text-xs font-semibold text-error">{error}</p>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/lib/api.ts
Normal file
24
src/lib/api.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export const API_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
|
||||
|
||||
export function makeHeaders(
|
||||
token?: string,
|
||||
options?: { includeTenantId?: boolean }
|
||||
): Record<string, string> {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
const requestTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||
const refNo = `REF${requestTime.replace(/[^0-9]/g, "")}${Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, "0")}`;
|
||||
const tenantId = process.env.NEXT_PUBLIC_TENANT_ID;
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Request-Time": requestTime,
|
||||
"Channel-Id": "WEB",
|
||||
"Reference-Number": refNo,
|
||||
...(options?.includeTenantId && tenantId ? { "Tenant-Id": tenantId } : {}),
|
||||
...(token ? { Authorization: token } : {}),
|
||||
};
|
||||
}
|
||||
130
src/lib/countries.ts
Normal file
130
src/lib/countries.ts
Normal file
@ -0,0 +1,130 @@
|
||||
export const COUNTRIES: { name: string; code: string }[] = [
|
||||
{ name: "Afghanistan", code: "AF" },
|
||||
{ name: "Albania", code: "AL" },
|
||||
{ name: "Algeria", code: "DZ" },
|
||||
{ name: "Andorra", code: "AD" },
|
||||
{ name: "Angola", code: "AO" },
|
||||
{ name: "Argentina", code: "AR" },
|
||||
{ name: "Armenia", code: "AM" },
|
||||
{ name: "Australia", code: "AU" },
|
||||
{ name: "Austria", code: "AT" },
|
||||
{ name: "Azerbaijan", code: "AZ" },
|
||||
{ name: "Bahrain", code: "BH" },
|
||||
{ name: "Bangladesh", code: "BD" },
|
||||
{ name: "Belarus", code: "BY" },
|
||||
{ name: "Belgium", code: "BE" },
|
||||
{ name: "Bolivia", code: "BO" },
|
||||
{ name: "Bosnia and Herzegovina", code: "BA" },
|
||||
{ name: "Brazil", code: "BR" },
|
||||
{ name: "Brunei", code: "BN" },
|
||||
{ name: "Bulgaria", code: "BG" },
|
||||
{ name: "Cambodia", code: "KH" },
|
||||
{ name: "Cameroon", code: "CM" },
|
||||
{ name: "Canada", code: "CA" },
|
||||
{ name: "Chile", code: "CL" },
|
||||
{ name: "China", code: "CN" },
|
||||
{ name: "Colombia", code: "CO" },
|
||||
{ name: "Croatia", code: "HR" },
|
||||
{ name: "Cuba", code: "CU" },
|
||||
{ name: "Cyprus", code: "CY" },
|
||||
{ name: "Czech Republic", code: "CZ" },
|
||||
{ name: "Denmark", code: "DK" },
|
||||
{ name: "Ecuador", code: "EC" },
|
||||
{ name: "Egypt", code: "EG" },
|
||||
{ name: "Estonia", code: "EE" },
|
||||
{ name: "Ethiopia", code: "ET" },
|
||||
{ name: "Finland", code: "FI" },
|
||||
{ name: "France", code: "FR" },
|
||||
{ name: "Georgia", code: "GE" },
|
||||
{ name: "Germany", code: "DE" },
|
||||
{ name: "Ghana", code: "GH" },
|
||||
{ name: "Greece", code: "GR" },
|
||||
{ name: "Guatemala", code: "GT" },
|
||||
{ name: "Honduras", code: "HN" },
|
||||
{ name: "Hong Kong", code: "HK" },
|
||||
{ name: "Hungary", code: "HU" },
|
||||
{ name: "Iceland", code: "IS" },
|
||||
{ name: "India", code: "IN" },
|
||||
{ name: "Indonesia", code: "ID" },
|
||||
{ name: "Iran", code: "IR" },
|
||||
{ name: "Iraq", code: "IQ" },
|
||||
{ name: "Ireland", code: "IE" },
|
||||
{ name: "Israel", code: "IL" },
|
||||
{ name: "Italy", code: "IT" },
|
||||
{ name: "Japan", code: "JP" },
|
||||
{ name: "Jordan", code: "JO" },
|
||||
{ name: "Kazakhstan", code: "KZ" },
|
||||
{ name: "Kenya", code: "KE" },
|
||||
{ name: "Kuwait", code: "KW" },
|
||||
{ name: "Kyrgyzstan", code: "KG" },
|
||||
{ name: "Laos", code: "LA" },
|
||||
{ name: "Latvia", code: "LV" },
|
||||
{ name: "Lebanon", code: "LB" },
|
||||
{ name: "Libya", code: "LY" },
|
||||
{ name: "Lithuania", code: "LT" },
|
||||
{ name: "Luxembourg", code: "LU" },
|
||||
{ name: "Macau", code: "MO" },
|
||||
{ name: "Malaysia", code: "MY" },
|
||||
{ name: "Maldives", code: "MV" },
|
||||
{ name: "Mexico", code: "MX" },
|
||||
{ name: "Moldova", code: "MD" },
|
||||
{ name: "Mongolia", code: "MN" },
|
||||
{ name: "Morocco", code: "MA" },
|
||||
{ name: "Mozambique", code: "MZ" },
|
||||
{ name: "Myanmar", code: "MM" },
|
||||
{ name: "Nepal", code: "NP" },
|
||||
{ name: "Netherlands", code: "NL" },
|
||||
{ name: "New Zealand", code: "NZ" },
|
||||
{ name: "Nigeria", code: "NG" },
|
||||
{ name: "North Korea", code: "KP" },
|
||||
{ name: "Norway", code: "NO" },
|
||||
{ name: "Oman", code: "OM" },
|
||||
{ name: "Pakistan", code: "PK" },
|
||||
{ name: "Palestine", code: "PS" },
|
||||
{ name: "Panama", code: "PA" },
|
||||
{ name: "Papua New Guinea", code: "PG" },
|
||||
{ name: "Paraguay", code: "PY" },
|
||||
{ name: "Peru", code: "PE" },
|
||||
{ name: "Philippines", code: "PH" },
|
||||
{ name: "Poland", code: "PL" },
|
||||
{ name: "Portugal", code: "PT" },
|
||||
{ name: "Qatar", code: "QA" },
|
||||
{ name: "Romania", code: "RO" },
|
||||
{ name: "Russia", code: "RU" },
|
||||
{ name: "Rwanda", code: "RW" },
|
||||
{ name: "Saudi Arabia", code: "SA" },
|
||||
{ name: "Senegal", code: "SN" },
|
||||
{ name: "Serbia", code: "RS" },
|
||||
{ name: "Singapore", code: "SG" },
|
||||
{ name: "Slovakia", code: "SK" },
|
||||
{ name: "Slovenia", code: "SI" },
|
||||
{ name: "Somalia", code: "SO" },
|
||||
{ name: "South Africa", code: "ZA" },
|
||||
{ name: "South Korea", code: "KR" },
|
||||
{ name: "Spain", code: "ES" },
|
||||
{ name: "Sri Lanka", code: "LK" },
|
||||
{ name: "Sudan", code: "SD" },
|
||||
{ name: "Sweden", code: "SE" },
|
||||
{ name: "Switzerland", code: "CH" },
|
||||
{ name: "Syria", code: "SY" },
|
||||
{ name: "Taiwan", code: "TW" },
|
||||
{ name: "Tajikistan", code: "TJ" },
|
||||
{ name: "Tanzania", code: "TZ" },
|
||||
{ name: "Thailand", code: "TH" },
|
||||
{ name: "Timor-Leste", code: "TL" },
|
||||
{ name: "Tunisia", code: "TN" },
|
||||
{ name: "Turkey", code: "TR" },
|
||||
{ name: "Turkmenistan", code: "TM" },
|
||||
{ name: "Uganda", code: "UG" },
|
||||
{ name: "Ukraine", code: "UA" },
|
||||
{ name: "United Arab Emirates", code: "AE" },
|
||||
{ name: "United Kingdom", code: "GB" },
|
||||
{ name: "United States", code: "US" },
|
||||
{ name: "Uruguay", code: "UY" },
|
||||
{ name: "Uzbekistan", code: "UZ" },
|
||||
{ name: "Venezuela", code: "VE" },
|
||||
{ name: "Vietnam", code: "VN" },
|
||||
{ name: "Yemen", code: "YE" },
|
||||
{ name: "Zambia", code: "ZM" },
|
||||
{ name: "Zimbabwe", code: "ZW" },
|
||||
];
|
||||
51
src/lib/i18n-context.tsx
Normal file
51
src/lib/i18n-context.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { id } from "./translations/id";
|
||||
import { en } from "./translations/en";
|
||||
|
||||
export type Lang = "id" | "en";
|
||||
export type Translations = typeof id;
|
||||
|
||||
interface I18nContextValue {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
toggleLang: () => void;
|
||||
t: Translations;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>({
|
||||
lang: "id",
|
||||
setLang: () => {},
|
||||
toggleLang: () => {},
|
||||
t: id,
|
||||
});
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(() => {
|
||||
if (typeof window === "undefined") return "id";
|
||||
const stored = localStorage.getItem("lang") as Lang | null;
|
||||
return stored === "id" || stored === "en" ? stored : "id";
|
||||
});
|
||||
|
||||
function setLang(newLang: Lang) {
|
||||
setLangState(newLang);
|
||||
localStorage.setItem("lang", newLang);
|
||||
}
|
||||
|
||||
function toggleLang() {
|
||||
setLang(lang === "id" ? "en" : "id");
|
||||
}
|
||||
|
||||
const t = lang === "en" ? en : id;
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={{ lang, setLang, toggleLang, t }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
43
src/lib/mailer.ts
Normal file
43
src/lib/mailer.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 465,
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
export async function sendOTPEmail(to: string, otp: string): Promise<void> {
|
||||
await transporter.sendMail({
|
||||
from: `"Ina Trading" <${process.env.SMTP_FROM}>`,
|
||||
to,
|
||||
subject: "Kode Verifikasi Email - Ina Trading",
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
|
||||
<div style="margin-bottom: 32px;">
|
||||
<h1 style="color: #b7131a; font-size: 28px; margin: 0;">INA<span style="color: #1a237e;">TRADING</span></h1>
|
||||
<p style="color: #666; font-size: 12px; margin: 4px 0 0;">Digital Security Technology by PERURI</p>
|
||||
</div>
|
||||
<h2 style="color: #191c1d; font-size: 22px; margin-bottom: 8px;">Verifikasi Email Anda</h2>
|
||||
<p style="color: #5b403d; margin-bottom: 24px;">
|
||||
Gunakan kode berikut untuk memverifikasi alamat email Anda. Kode berlaku selama <strong>10 menit</strong>.
|
||||
</p>
|
||||
<div style="background: #f3f4f5; border-radius: 8px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||||
<div style="font-size: 48px; font-weight: bold; letter-spacing: 16px; color: #b7131a; font-family: monospace;">
|
||||
${otp}
|
||||
</div>
|
||||
</div>
|
||||
<p style="color: #906f6c; font-size: 13px; line-height: 1.6;">
|
||||
Jika Anda tidak meminta kode ini, abaikan email ini. Akun Anda tetap aman.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e4beb9; margin: 32px 0;" />
|
||||
<p style="color: #906f6c; font-size: 11px;">
|
||||
© ${new Date().getFullYear()} Ina Trading. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
244
src/lib/product-draft.tsx
Normal file
244
src/lib/product-draft.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export type ProductStep =
|
||||
| "category"
|
||||
| "details"
|
||||
| "pricing"
|
||||
| "specifications"
|
||||
| "review";
|
||||
|
||||
export interface ProductWarehouseStock {
|
||||
id: string;
|
||||
name: string;
|
||||
stock: number;
|
||||
}
|
||||
|
||||
export interface ProductMeasurementDraft {
|
||||
id: string;
|
||||
measurementType: string;
|
||||
measurementValue: string;
|
||||
price: string;
|
||||
currency: string;
|
||||
weight: string;
|
||||
weightType: string;
|
||||
length: string;
|
||||
width: string;
|
||||
height: string;
|
||||
dimensionType: string;
|
||||
packagingWeight: string;
|
||||
packagingWeightType: string;
|
||||
packagingLength: string;
|
||||
packagingWidth: string;
|
||||
packagingHeight: string;
|
||||
packagingDimensionType: string;
|
||||
hasPromotion: boolean;
|
||||
promotionPrice: string;
|
||||
promotionCurrency: string;
|
||||
promotionStartDate: string;
|
||||
promotionEndDate: string;
|
||||
warehouses: ProductWarehouseStock[];
|
||||
}
|
||||
|
||||
export interface ProductModelDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
imageId: string;
|
||||
price: string;
|
||||
currency: string;
|
||||
weight: string;
|
||||
weightType: string;
|
||||
length: string;
|
||||
width: string;
|
||||
height: string;
|
||||
dimensionType: string;
|
||||
packagingWeight: string;
|
||||
packagingWeightType: string;
|
||||
packagingLength: string;
|
||||
packagingWidth: string;
|
||||
packagingHeight: string;
|
||||
packagingDimensionType: string;
|
||||
hasPromotion: boolean;
|
||||
promotionPrice: string;
|
||||
promotionCurrency: string;
|
||||
promotionStartDate: string;
|
||||
promotionEndDate: string;
|
||||
hasMeasurements: boolean;
|
||||
warehouses: ProductWarehouseStock[];
|
||||
measurements: ProductMeasurementDraft[];
|
||||
}
|
||||
|
||||
export interface ProductDraftState {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
subCategoryId: string;
|
||||
subCategoryName: string;
|
||||
subCategoryAttributes: Array<{ id: string; paramName: string }>;
|
||||
name: string;
|
||||
description: string;
|
||||
isPreOrder: boolean;
|
||||
preOrderDay: string;
|
||||
isNew: boolean;
|
||||
isEligibleToExport: boolean;
|
||||
keywords: string[];
|
||||
features: string[];
|
||||
imageId: string;
|
||||
productFiles: Array<{ id: string; name: string }>;
|
||||
productImages: string[];
|
||||
productInformations: Array<{ paramName: string; paramValue: string }>;
|
||||
categoryInformations: Array<{ paramName: string; paramValue: string }>;
|
||||
complianceInformation: {
|
||||
safetyWarning: string;
|
||||
countryOfOrigin: string;
|
||||
isDangerousGoodRegulation: boolean;
|
||||
fileId: string;
|
||||
};
|
||||
warrantyInformation: {
|
||||
type: string;
|
||||
duration: string;
|
||||
durationType: string;
|
||||
};
|
||||
models: ProductModelDraft[];
|
||||
}
|
||||
|
||||
interface ProductDraftContextValue {
|
||||
draft: ProductDraftState;
|
||||
setDraft: React.Dispatch<React.SetStateAction<ProductDraftState>>;
|
||||
resetDraft: () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "productWizardDraft";
|
||||
|
||||
const defaultDraft: ProductDraftState = {
|
||||
categoryId: "",
|
||||
categoryName: "",
|
||||
subCategoryId: "",
|
||||
subCategoryName: "",
|
||||
subCategoryAttributes: [],
|
||||
name: "",
|
||||
description: "",
|
||||
isPreOrder: false,
|
||||
preOrderDay: "",
|
||||
isNew: true,
|
||||
isEligibleToExport: true,
|
||||
keywords: [],
|
||||
features: [],
|
||||
imageId: "",
|
||||
productFiles: [],
|
||||
productImages: [],
|
||||
productInformations: [
|
||||
{ paramName: "Brand", paramValue: "" },
|
||||
{ paramName: "Material", paramValue: "" },
|
||||
],
|
||||
categoryInformations: [
|
||||
{ paramName: "Primary Use", paramValue: "" },
|
||||
{ paramName: "Market Segment", paramValue: "" },
|
||||
],
|
||||
complianceInformation: {
|
||||
safetyWarning: "",
|
||||
countryOfOrigin: "Indonesia",
|
||||
isDangerousGoodRegulation: false,
|
||||
fileId: "",
|
||||
},
|
||||
warrantyInformation: {
|
||||
type: "",
|
||||
duration: "",
|
||||
durationType: "MONTH",
|
||||
},
|
||||
models: [
|
||||
{
|
||||
id: "model-1",
|
||||
name: "",
|
||||
sku: "",
|
||||
imageId: "",
|
||||
price: "",
|
||||
currency: "IDR",
|
||||
weight: "",
|
||||
weightType: "G",
|
||||
length: "",
|
||||
width: "",
|
||||
height: "",
|
||||
dimensionType: "CM",
|
||||
packagingWeight: "",
|
||||
packagingWeightType: "G",
|
||||
packagingLength: "",
|
||||
packagingWidth: "",
|
||||
packagingHeight: "",
|
||||
packagingDimensionType: "CM",
|
||||
hasPromotion: false,
|
||||
promotionPrice: "",
|
||||
promotionCurrency: "IDR",
|
||||
promotionStartDate: "",
|
||||
promotionEndDate: "",
|
||||
hasMeasurements: false,
|
||||
warehouses: [{ id: "", name: "", stock: 0 }],
|
||||
measurements: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ProductDraftContext = createContext<ProductDraftContextValue | null>(null);
|
||||
|
||||
export function ProductDraftProvider({ children }: { children: ReactNode }) {
|
||||
const [draft, setDraft] = useState<ProductDraftState>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultDraft;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return defaultDraft;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(raw);
|
||||
// Merge with defaultDraft so any new fields added to the schema
|
||||
// always have a valid default value (handles stale sessionStorage).
|
||||
return { ...defaultDraft, ...stored } as ProductDraftState;
|
||||
} catch {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return defaultDraft;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
|
||||
}, [draft]);
|
||||
|
||||
function resetDraft() {
|
||||
setDraft(defaultDraft);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProductDraftContext.Provider value={{ draft, setDraft, resetDraft }}>
|
||||
{children}
|
||||
</ProductDraftContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProductDraft() {
|
||||
const context = useContext(ProductDraftContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useProductDraft must be used within ProductDraftProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export const productStepOrder: ProductStep[] = [
|
||||
"category",
|
||||
"details",
|
||||
"pricing",
|
||||
"specifications",
|
||||
"review",
|
||||
];
|
||||
88
src/lib/product-options.ts
Normal file
88
src/lib/product-options.ts
Normal file
@ -0,0 +1,88 @@
|
||||
export const WEIGHT_TYPES = [
|
||||
{ value: "G", label: "Grams (g)" },
|
||||
{ value: "KG", label: "Kilograms (kg)" },
|
||||
{ value: "LB", label: "Pounds (lb)" },
|
||||
{ value: "OZ", label: "Ounces (oz)" },
|
||||
];
|
||||
|
||||
export const DIMENSION_TYPES = [
|
||||
{ value: "CM", label: "Centimeters (cm)" },
|
||||
{ value: "MM", label: "Millimeters (mm)" },
|
||||
{ value: "IN", label: "Inches (in)" },
|
||||
{ value: "FT", label: "Feet (ft)" },
|
||||
];
|
||||
|
||||
export const WORLD_CURRENCIES = [
|
||||
{ value: "IDR", label: "IDR — Indonesian Rupiah" },
|
||||
{ value: "USD", label: "USD — US Dollar" },
|
||||
{ value: "EUR", label: "EUR — Euro" },
|
||||
{ value: "GBP", label: "GBP — British Pound" },
|
||||
{ value: "JPY", label: "JPY — Japanese Yen" },
|
||||
{ value: "CNY", label: "CNY — Chinese Yuan" },
|
||||
{ value: "KRW", label: "KRW — South Korean Won" },
|
||||
{ value: "SGD", label: "SGD — Singapore Dollar" },
|
||||
{ value: "MYR", label: "MYR — Malaysian Ringgit" },
|
||||
{ value: "THB", label: "THB — Thai Baht" },
|
||||
{ value: "PHP", label: "PHP — Philippine Peso" },
|
||||
{ value: "VND", label: "VND — Vietnamese Dong" },
|
||||
{ value: "AUD", label: "AUD — Australian Dollar" },
|
||||
{ value: "CAD", label: "CAD — Canadian Dollar" },
|
||||
{ value: "CHF", label: "CHF — Swiss Franc" },
|
||||
{ value: "HKD", label: "HKD — Hong Kong Dollar" },
|
||||
{ value: "NZD", label: "NZD — New Zealand Dollar" },
|
||||
{ value: "SEK", label: "SEK — Swedish Krona" },
|
||||
{ value: "NOK", label: "NOK — Norwegian Krone" },
|
||||
{ value: "DKK", label: "DKK — Danish Krone" },
|
||||
{ value: "INR", label: "INR — Indian Rupee" },
|
||||
{ value: "PKR", label: "PKR — Pakistani Rupee" },
|
||||
{ value: "BDT", label: "BDT — Bangladeshi Taka" },
|
||||
{ value: "LKR", label: "LKR — Sri Lankan Rupee" },
|
||||
{ value: "NPR", label: "NPR — Nepalese Rupee" },
|
||||
{ value: "MMK", label: "MMK — Myanmar Kyat" },
|
||||
{ value: "KHR", label: "KHR — Cambodian Riel" },
|
||||
{ value: "LAK", label: "LAK — Lao Kip" },
|
||||
{ value: "BND", label: "BND — Brunei Dollar" },
|
||||
{ value: "MOP", label: "MOP — Macanese Pataca" },
|
||||
{ value: "TWD", label: "TWD — Taiwan Dollar" },
|
||||
{ value: "AED", label: "AED — UAE Dirham" },
|
||||
{ value: "SAR", label: "SAR — Saudi Riyal" },
|
||||
{ value: "QAR", label: "QAR — Qatari Riyal" },
|
||||
{ value: "KWD", label: "KWD — Kuwaiti Dinar" },
|
||||
{ value: "BHD", label: "BHD — Bahraini Dinar" },
|
||||
{ value: "OMR", label: "OMR — Omani Rial" },
|
||||
{ value: "JOD", label: "JOD — Jordanian Dinar" },
|
||||
{ value: "EGP", label: "EGP — Egyptian Pound" },
|
||||
{ value: "TRY", label: "TRY — Turkish Lira" },
|
||||
{ value: "ILS", label: "ILS — Israeli Shekel" },
|
||||
{ value: "ZAR", label: "ZAR — South African Rand" },
|
||||
{ value: "NGN", label: "NGN — Nigerian Naira" },
|
||||
{ value: "KES", label: "KES — Kenyan Shilling" },
|
||||
{ value: "GHS", label: "GHS — Ghanaian Cedi" },
|
||||
{ value: "EGP", label: "EGP — Egyptian Pound" },
|
||||
{ value: "MAD", label: "MAD — Moroccan Dirham" },
|
||||
{ value: "TND", label: "TND — Tunisian Dinar" },
|
||||
{ value: "BRL", label: "BRL — Brazilian Real" },
|
||||
{ value: "ARS", label: "ARS — Argentine Peso" },
|
||||
{ value: "CLP", label: "CLP — Chilean Peso" },
|
||||
{ value: "COP", label: "COP — Colombian Peso" },
|
||||
{ value: "PEN", label: "PEN — Peruvian Sol" },
|
||||
{ value: "MXN", label: "MXN — Mexican Peso" },
|
||||
{ value: "CRC", label: "CRC — Costa Rican Colón" },
|
||||
{ value: "RUB", label: "RUB — Russian Ruble" },
|
||||
{ value: "UAH", label: "UAH — Ukrainian Hryvnia" },
|
||||
{ value: "PLN", label: "PLN — Polish Zloty" },
|
||||
{ value: "CZK", label: "CZK — Czech Koruna" },
|
||||
{ value: "HUF", label: "HUF — Hungarian Forint" },
|
||||
{ value: "RON", label: "RON — Romanian Leu" },
|
||||
{ value: "BGN", label: "BGN — Bulgarian Lev" },
|
||||
{ value: "HRK", label: "HRK — Croatian Kuna" },
|
||||
{ value: "RSD", label: "RSD — Serbian Dinar" },
|
||||
{ value: "ALL", label: "ALL — Albanian Lek" },
|
||||
{ value: "MKD", label: "MKD — Macedonian Denar" },
|
||||
{ value: "BYN", label: "BYN — Belarusian Ruble" },
|
||||
{ value: "KZT", label: "KZT — Kazakhstani Tenge" },
|
||||
{ value: "UZS", label: "UZS — Uzbekistani Som" },
|
||||
{ value: "GEL", label: "GEL — Georgian Lari" },
|
||||
{ value: "AMD", label: "AMD — Armenian Dram" },
|
||||
{ value: "AZN", label: "AZN — Azerbaijani Manat" },
|
||||
];
|
||||
5
src/lib/redis.ts
Normal file
5
src/lib/redis.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
|
||||
|
||||
export default redis;
|
||||
743
src/lib/translations/en.ts
Normal file
743
src/lib/translations/en.ts
Normal file
@ -0,0 +1,743 @@
|
||||
export const en = {
|
||||
common: {
|
||||
loading: "Processing...",
|
||||
connectionError: "Connection failed, please try again",
|
||||
cancel: "Cancel",
|
||||
or: "or",
|
||||
help: "Help",
|
||||
privacy: "Privacy Policy",
|
||||
terms: "Terms & Conditions",
|
||||
logout: "Logout",
|
||||
uploaded: "Uploaded",
|
||||
delete: "Delete",
|
||||
back: "Back",
|
||||
},
|
||||
auth: {
|
||||
login: {
|
||||
title: "Welcome Back",
|
||||
subtitle: "Log in to your business dashboard.",
|
||||
emailOrPhone: "Email or Phone Number",
|
||||
password: "Password",
|
||||
forgotPassword: "Forgot password?",
|
||||
rememberDevice: "Remember this device for 30 days",
|
||||
submit: "Sign In",
|
||||
submitting: "Processing...",
|
||||
noAccount: "Don't have an account?",
|
||||
registerFree: "Register for free",
|
||||
errorGeneric: "An error occurred, please try again",
|
||||
errorConnection: "Connection failed, please try again",
|
||||
marketStatus: "GLOBAL MARKETS ARE OPEN",
|
||||
heroTitle: "Access\nthe Global\nMarket",
|
||||
heroSubtitle:
|
||||
"Empowering local enterprises with institutional-grade financial intelligence and global logistics connectivity.",
|
||||
successRate: "Success Rate",
|
||||
marketAccess: "Market Access",
|
||||
countries: "Countries",
|
||||
},
|
||||
register: {
|
||||
title: "Create Account",
|
||||
subtitle: "Start your trading journey today",
|
||||
joinNetwork: "Join the\nGlobal Network.",
|
||||
joinSubtitle:
|
||||
"Empowering UMKM traders with real-time financial metrics and prestigious editorial data insights.",
|
||||
registerAs: "Register as",
|
||||
sellerDesc: "Sell products to the global market",
|
||||
buyerDesc: "Buy products from Indonesian SMEs",
|
||||
email: "Email",
|
||||
emailFromPrevious: "Email from previous step, cannot be changed",
|
||||
fullName: "Full Name",
|
||||
phone: "Phone Number",
|
||||
password: "Password",
|
||||
passwordPlaceholder: "Minimum 6 characters",
|
||||
confirmPassword: "Confirm Password",
|
||||
confirmPasswordPlaceholder: "Repeat password",
|
||||
submit: "Verify Email",
|
||||
submitting: "Sending OTP...",
|
||||
haveAccount: "Already have an account?",
|
||||
signIn: "Sign In",
|
||||
passwordMismatch: "Passwords do not match",
|
||||
passwordTooShort: "Password must be at least 6 characters",
|
||||
otpError: "Failed to send OTP, please try again",
|
||||
stepOf: "Step 01 of 03",
|
||||
termsAgreement: "By creating an account, you agree to the",
|
||||
and: "and",
|
||||
inaTrading: "Ina Trading.",
|
||||
successRate: "Success Rate",
|
||||
marketPulse: "Market Pulse",
|
||||
},
|
||||
verify: {
|
||||
title: "Verify email",
|
||||
subtitle: "We have sent a 6-digit code to",
|
||||
subtitleSuffix: ". Enter the code below.",
|
||||
noCode: "Didn't receive the code?",
|
||||
resend: "Resend OTP",
|
||||
resending: "Sending...",
|
||||
resendCountdown: "Resend",
|
||||
submit: "Verify OTP",
|
||||
submitting: "Verifying...",
|
||||
otpTooShort: "Enter the 6-digit OTP code",
|
||||
noData: "Registration data not found, please restart the registration",
|
||||
verifyFail: "Verification failed",
|
||||
registerFail: "Seller registration failed",
|
||||
successSeller:
|
||||
"OTP valid and seller account created. Redirecting to business data...",
|
||||
successBuyer:
|
||||
"OTP successfully verified. Redirecting to the next step...",
|
||||
securityTitle: "Institutional-Grade Security",
|
||||
securityDesc:
|
||||
"By continuing, you agree to Ina Trading's Terms & Conditions and Privacy Policy. Your data is protected with advanced encryption.",
|
||||
secureYourFuture: "SECURE\nYOUR\nFUTURE.",
|
||||
transactionSecurity: "Transaction Security",
|
||||
bankLevelEncryption: "Bank-Level Encryption",
|
||||
verifyIdentity:
|
||||
"Verifying your identity is the first step toward global financial mastery.",
|
||||
},
|
||||
complete: {
|
||||
title: "Finalize Registration",
|
||||
subtitle: "OTP is valid. Your account will be created in this final step.",
|
||||
submit: "Create Ina Trading Account",
|
||||
submitting: "Processing...",
|
||||
noData: "Registration data not found",
|
||||
registerFail: "Registration failed",
|
||||
backToRegister: "Back to registration",
|
||||
finalStep: "Final Step",
|
||||
},
|
||||
forgotPassword: {
|
||||
title: "Forgot password",
|
||||
subtitle:
|
||||
"Please enter your registered email address or phone number to receive a secure reset link.",
|
||||
emailOrPhone: "Email or Phone Number",
|
||||
submit: "Send",
|
||||
backToLogin: "Back to login",
|
||||
havingTrouble: "Having trouble?",
|
||||
supportLink: "Contact our 24/7 support team.",
|
||||
securityFirst: "SECURITY FIRST",
|
||||
heroTitle: "Safeguarding\nYour Growth.",
|
||||
heroSubtitle:
|
||||
"At Ina Trading, we treat your business metrics with the prestige of a global broadcaster. Let's get you back to your dashboard.",
|
||||
uptime: "Uptime reliability",
|
||||
fraudMonitoring: "Fraud monitoring",
|
||||
apiNotReady:
|
||||
"Reset password API not yet available. This screen is ready and the request has been simulated for",
|
||||
},
|
||||
accountNotFound: {
|
||||
title: "Account not found",
|
||||
change: "Change?",
|
||||
createAccount: "Create Account",
|
||||
loginOther: "Sign in with a different email or number",
|
||||
disclaimer:
|
||||
"Ina Trading is a regulated trading entity. Digital assets and global trading instruments carry significant risks. Ensure you have read our regulatory disclosures before creating an account.",
|
||||
editorialIntelligence: "Editorial Intelligence",
|
||||
tradePrecision: "TRADE WITH\nPRECISION.",
|
||||
tradeSubtitle:
|
||||
"Access institutional-grade market tools and global supply chain insights tailored for modern trading operations.",
|
||||
marketStatus: "MARKET STATUS: ACTIVE — VOLATILITY LOW — TRADE NOW",
|
||||
helpLink: "Help",
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
layout: {
|
||||
setupGuide: "Setup Guide",
|
||||
progress: "Onboarding Progress",
|
||||
business: "Business",
|
||||
storeDetail: "Store Detail",
|
||||
plan: "Plan",
|
||||
proTip: "Pro Tip",
|
||||
proTipText:
|
||||
"Verify your business address to unlock global trading limits.",
|
||||
stepOf: "of",
|
||||
},
|
||||
business: {
|
||||
pageTitle: "Complete your",
|
||||
pageTitleHighlight: "business",
|
||||
pageTitleSuffix: "data.",
|
||||
pageSubtitle:
|
||||
"This information is used to customize your dashboard and compliance profile.",
|
||||
coreInfo: "Core Information",
|
||||
regionalDetails: "Regional Details",
|
||||
bankingInfo: "Banking Information",
|
||||
legalizationDocs: "Legalization Documents",
|
||||
businessName: "Business Name",
|
||||
businessNamePlaceholder: "Legal Entity Name",
|
||||
businessAddress: "Business Address",
|
||||
businessAddressPlaceholder: "Street name, building, floor...",
|
||||
country: "Country",
|
||||
businessPhone: "Business Phone Number",
|
||||
province: "Province / State",
|
||||
selectProvince: "Select Province",
|
||||
provincePlaceholder: "Province / State",
|
||||
city: "City",
|
||||
selectCity: "Select City",
|
||||
selectProvincFirst: "Select a province first",
|
||||
loadingCities: "Loading cities...",
|
||||
cityPlaceholder: "City",
|
||||
postalCode: "Postal Code",
|
||||
bankName: "Bank Name",
|
||||
selectBank: "Select Bank",
|
||||
bankNamePlaceholder: "Bank name",
|
||||
accountNumber: "Account Number",
|
||||
uploadNew: "Upload New",
|
||||
docsNote:
|
||||
"Please provide supporting files such as NIB, NPWP, or Deed of Establishment.",
|
||||
uploadHint: "Drag & Drop or Click to Add Document",
|
||||
docNoRequired: "Please upload at least 1 legal document",
|
||||
saveError: "Failed to save business data",
|
||||
genericError: "An error occurred",
|
||||
submitting: "Saving...",
|
||||
submit: "Continue",
|
||||
logout: "Logout",
|
||||
failLoadProvinces: "Failed to load province list",
|
||||
failLoadCities: "Failed to load cities",
|
||||
uploadFail: "Upload failed",
|
||||
sessionExpired: "Your login session has expired. Please sign in again before uploading documents.",
|
||||
globalReach: "Global Reach,\nLocal Roots.",
|
||||
complianceNote: "Compliance Note",
|
||||
complianceDesc:
|
||||
"Ensure the business name matches the official registration documents for faster verification.",
|
||||
docDetail: "Document Details",
|
||||
docType: "Document Type",
|
||||
otherDocType: "Other Document Type",
|
||||
otherDocTypePlaceholder: "Enter document type",
|
||||
docNumber: "Document Number",
|
||||
docNumberPlaceholder: "Document number",
|
||||
publishedDate: "Issue Date",
|
||||
validDate: "Valid Until",
|
||||
docDetailRequired: "Please complete all document details first",
|
||||
addDocument: "Add Document",
|
||||
uploading: "Uploading...",
|
||||
uploadMissingFileId: "Upload completed but no fileId was returned by the backend",
|
||||
},
|
||||
storeDetail: {
|
||||
title: "Store Configuration",
|
||||
subtitle:
|
||||
"Finalize your digital storefront and logistics hub. This information will be visible to institutional partners and logistics providers.",
|
||||
storeInfo: "Store Information",
|
||||
storeName: "Store Name",
|
||||
storeNamePlaceholder: "Enter store name",
|
||||
storeBiography: "Store Biography",
|
||||
storeBiographyPlaceholder: "Tell us about your store...",
|
||||
storePhoto: "Store Photo",
|
||||
storePhotoHelper: "Upload a store photo or storefront logo",
|
||||
warehouseSetup: "Warehouse Setup",
|
||||
primaryHub: "Primary Hub",
|
||||
warehouseName: "Warehouse Name",
|
||||
warehouseNamePlaceholder: "Enter warehouse name",
|
||||
fullAddress: "Full Address",
|
||||
fullAddressPlaceholder: "Enter full warehouse address",
|
||||
warehouseType: "Warehouse Type",
|
||||
country: "Country",
|
||||
province: "Province",
|
||||
city: "City",
|
||||
postalCode: "Postal Code",
|
||||
latitude: "Latitude",
|
||||
longitude: "Longitude",
|
||||
other: "Other",
|
||||
back: "Back to Business Information",
|
||||
termsNote:
|
||||
"By continuing, you agree to our institutional terms of service.",
|
||||
continue: "Continue",
|
||||
saving: "Saving...",
|
||||
requiredError: "Please complete the store and warehouse details first",
|
||||
genericError: "An error occurred while saving store details",
|
||||
},
|
||||
plan: {
|
||||
step: "Step 03 of 03",
|
||||
title: "Select your growth engine",
|
||||
subtitle:
|
||||
"Choose a subscription plan that aligns with your UMKM scale. No hidden fees, just pure institutional-grade trading power.",
|
||||
selected: "Selected",
|
||||
currentStep: "Current Step",
|
||||
selectPlan: "Select Plan",
|
||||
contactUs: "Contact Us",
|
||||
back: "Back",
|
||||
skip: "Skip for now",
|
||||
submitSelection: "Submit Selection",
|
||||
testimonial:
|
||||
'"The Professional plan gave our SME the liquidity data we needed to scale three times faster than expected."',
|
||||
testimonialAuthor: "Hendra Wijaya, CEO of TaniTrade",
|
||||
userSuccessRate: "User Success Rate",
|
||||
mostPopular: "Most Popular",
|
||||
features: {
|
||||
basicAnalytics: "Basic Analytics",
|
||||
upTo10Trades: "Up to 10 Trades",
|
||||
apiAccess: "API Access",
|
||||
advancedReports: "Advanced Reports",
|
||||
unlimitedTrades: "Unlimited Trades",
|
||||
exportToExcel: "Export to Excel",
|
||||
fullApiAccess: "Full API Access",
|
||||
customDashboards: "Custom Dashboards",
|
||||
teamManagement: "Team Management",
|
||||
slaGuarantee: "SLA Guarantee",
|
||||
dedicatedManager: "Dedicated Manager",
|
||||
customIntegrations: "Custom Integrations",
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
layout: {
|
||||
searchPlaceholder: "Search operations...",
|
||||
editorial: "EDITORIAL",
|
||||
globalExportHub: "Global Export Hub",
|
||||
helpCenter: "Help Center",
|
||||
exportReport: "EXPORT REPORT",
|
||||
nav: {
|
||||
Dashboard: "Dashboard",
|
||||
Product: "Product",
|
||||
Warehouse: "Warehouse",
|
||||
Orders: "Orders",
|
||||
Invoice: "Invoice",
|
||||
Settings: "Settings",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
account: "Account",
|
||||
profile: "Profile",
|
||||
changePassword: "Change Password",
|
||||
sellerProfile: "Seller Profile",
|
||||
management: "Management",
|
||||
profileConfiguration: "Profile Configuration",
|
||||
editProfile: "Edit Profile",
|
||||
storeName: "Store Name",
|
||||
storeBiography: "Store Biography",
|
||||
storePhoto: "Store Photo",
|
||||
storeInfo: "Store Information",
|
||||
sellerId: "Seller ID",
|
||||
viewStorefront: "View Storefront",
|
||||
saveChanges: "Save Changes",
|
||||
saving: "Saving...",
|
||||
successUpdate: "Profile updated successfully.",
|
||||
errorLoad: "Failed to load profile",
|
||||
errorSave: "Failed to save profile",
|
||||
profileNotFound: "Profile data not found.",
|
||||
complianceNote: "Changes will be reviewed by the INA Marketplace Compliance team before going live.",
|
||||
loading: "Loading profile...",
|
||||
},
|
||||
changePassword: {
|
||||
title: "Change Password",
|
||||
settings: "Settings",
|
||||
security: "Security",
|
||||
currentPassword: "Current Password",
|
||||
newPassword: "New Password",
|
||||
confirmNewPassword: "Confirm New Password",
|
||||
updatePassword: "Update Password",
|
||||
cancel: "Cancel",
|
||||
saving: "Saving...",
|
||||
successMessage: "Password updated successfully.",
|
||||
errorRequired: "All fields are required.",
|
||||
errorMismatch: "Passwords do not match.",
|
||||
errorWeak: "New password is too weak. Use at least 8 characters with a mix of letters and numbers.",
|
||||
errorGeneric: "Failed to change password",
|
||||
mismatch: "Passwords do not match",
|
||||
strengthPrefix: "STRENGTH:",
|
||||
strengthWeak: "WEAK",
|
||||
strengthModerate: "MODERATE",
|
||||
strengthStrong: "STRONG",
|
||||
strengthVeryStrong: "VERY STRONG",
|
||||
guidelines: {
|
||||
title: "Security\nGuidelines",
|
||||
length: "Length Requirement",
|
||||
lengthDesc: "Minimum 8 characters. Complexity increases with every additional character.",
|
||||
mix: "Character Mix",
|
||||
mixDesc: "Include at least one uppercase letter, one number, and one unique symbol.",
|
||||
entropy: "Unique Entropy",
|
||||
entropyDesc: "Avoid repeating previous passwords or using common dictionary words.",
|
||||
lastChanged: "Last Changed: recently",
|
||||
},
|
||||
},
|
||||
products: {
|
||||
title: "Product Catalog",
|
||||
management: "Management",
|
||||
catalog: "Catalog Management",
|
||||
addProduct: "Add Product",
|
||||
totalItems: "Total Managed Items",
|
||||
activeCatalog: "Active Catalog",
|
||||
marketSplit: "Market Split:",
|
||||
marketSplitDesc: "International vs local market catalog distribution.",
|
||||
activeView: "Active View",
|
||||
loading: "Loading products...",
|
||||
empty: "No products found.",
|
||||
edit: "Edit",
|
||||
detail: "Detail",
|
||||
table: {
|
||||
product: "Product",
|
||||
price: "Price",
|
||||
stock: "Stock",
|
||||
market: "Market",
|
||||
action: "Action",
|
||||
showing: "Showing",
|
||||
of: "of",
|
||||
products: "products",
|
||||
},
|
||||
deleteDialog: {
|
||||
title: "Delete Product?",
|
||||
message: "This action cannot be undone.",
|
||||
productLabel: "Product to delete",
|
||||
cancel: "Cancel",
|
||||
confirm: "Yes, Delete",
|
||||
deleting: "Deleting...",
|
||||
errorGeneric: "Failed to delete product",
|
||||
},
|
||||
tabs: {
|
||||
allProduct: "All Product",
|
||||
draft: "Draft",
|
||||
inReview: "In Review",
|
||||
internationalMarket: "International Market",
|
||||
localMarket: "Local Market",
|
||||
outOfStock: "Out of Stock",
|
||||
rejected: "Rejected",
|
||||
},
|
||||
},
|
||||
productNew: {
|
||||
layout: {
|
||||
breadcrumbProducts: "Products",
|
||||
breadcrumbEditor: "Editor",
|
||||
breadcrumbNew: "New Listing",
|
||||
pageTitle: "Product Post: New Listing",
|
||||
pageSubtitle: "Define your product step by step. All progress is saved as a draft until you submit for review.",
|
||||
cancel: "Cancel",
|
||||
stepLabels: ["Basic Details", "Description", "Pricing & Model", "General Info", "Review"],
|
||||
},
|
||||
category: {
|
||||
title: "Product Listing",
|
||||
subtitle: "Precision starts with the right taxonomy.",
|
||||
mainCategory: "01. Main Category",
|
||||
subCategory: "02. Sub Category",
|
||||
loadingCategories: "Loading categories...",
|
||||
selectMain: "Select a primary department...",
|
||||
awaitingMain: "Awaiting main category selection...",
|
||||
loadingSubcategories: "Loading subcategories...",
|
||||
selectSub: "Select a sub category...",
|
||||
saveDraft: "Save as Draft",
|
||||
next: "Next: Description",
|
||||
cancel: "Cancel",
|
||||
autoSaved: "Auto-saved",
|
||||
errorLoadCat: "Failed to load categories",
|
||||
errorLoadSub: "Failed to load subcategories",
|
||||
},
|
||||
details: {
|
||||
title: "Description",
|
||||
officialName: "Official Product Name",
|
||||
officialNamePlaceholder: "e.g. Vintage Italian Leather Executive Chair",
|
||||
preOrder: "Pre-order Item",
|
||||
preOrderSub: "Ships in 2-3 weeks",
|
||||
brandNew: "Brand New",
|
||||
brandNewSub: "Original packaging",
|
||||
preOrderDay: "Pre-order Day",
|
||||
keywords: "Search Keywords",
|
||||
addKeyword: "Add keyword",
|
||||
addKeywordBtn: "Add",
|
||||
narrative: "Product Narrative",
|
||||
narrativePlaceholder: "Describe the craftsmanship, materials, and value proposition...",
|
||||
features: "Key Product Features",
|
||||
addFeature: "Add Feature",
|
||||
noFeatures: "No features added yet.",
|
||||
visualIdentity: "Visual Identity",
|
||||
addImage: "Add Image",
|
||||
noImages: "No images added yet.",
|
||||
back: "Back",
|
||||
next: "Next: Pricing",
|
||||
saveDraft: "Save as Draft",
|
||||
autoSaved: "Auto-saved",
|
||||
},
|
||||
pricing: {
|
||||
title: "Pricing & Model",
|
||||
addModel: "Add Model",
|
||||
modelName: "Model Name",
|
||||
sku: "SKU",
|
||||
price: "Price",
|
||||
currency: "Currency",
|
||||
weight: "Weight",
|
||||
weightUnit: "Weight Unit",
|
||||
length: "Length",
|
||||
width: "Width",
|
||||
height: "Height",
|
||||
dimensionUnit: "Dimension Unit",
|
||||
hasPromotion: "Configure Promotion Price",
|
||||
promotionPrice: "Promotion Price",
|
||||
promotionCurrency: "Promo Currency",
|
||||
promotionStart: "Promotion Start Date",
|
||||
promotionEnd: "Promotion End Date",
|
||||
packagingWeight: "Packaging Weight",
|
||||
packagingLength: "Pkg. Length",
|
||||
packagingWidth: "Pkg. Width",
|
||||
packagingHeight: "Pkg. Height",
|
||||
selectWarehouse: "Select Warehouse",
|
||||
stock: "Stock",
|
||||
addWarehouse: "Add Warehouse Stock",
|
||||
removeWarehouse: "Remove",
|
||||
measurement: "Measurement / Variant",
|
||||
addMeasurement: "Add Measurement",
|
||||
measurementType: "Measurement Type",
|
||||
measurementValue: "Measurement Value",
|
||||
noModels: "No models added yet.",
|
||||
removeModel: "Remove Model",
|
||||
removeMeasurement: "Remove Measurement",
|
||||
uploadImage: "Upload Model Image",
|
||||
changeImage: "Change Image",
|
||||
imageUploaded: "Image Uploaded",
|
||||
uploading: "Uploading...",
|
||||
back: "Back",
|
||||
next: "Next: Specifications",
|
||||
saveDraft: "Save as Draft",
|
||||
autoSaved: "Auto-saved",
|
||||
uploadFail: "Upload failed",
|
||||
},
|
||||
specifications: {
|
||||
title: "Specifications",
|
||||
generalInfo: "General Information",
|
||||
categoryInfo: "Category Information",
|
||||
compliance: "Compliance",
|
||||
warranty: "Warranty",
|
||||
paramName: "Parameter Name",
|
||||
paramValue: "Parameter Value",
|
||||
addRow: "Add Row",
|
||||
countryOfOrigin: "Country of Origin",
|
||||
safetyWarning: "Safety Warning",
|
||||
dangerousGoods: "Hazardous (Dangerous Goods)?",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
msds: "MSDS Document",
|
||||
supportingDocs: "Supporting Documents",
|
||||
warrantyType: "Warranty Type",
|
||||
warrantyDuration: "Duration",
|
||||
warrantyDurationType: "Duration Type",
|
||||
eligibleToExport: "Eligible to Export",
|
||||
awaitingCategory: "Select a sub-category in the first step first...",
|
||||
uploadFail: "Upload failed",
|
||||
back: "Back",
|
||||
next: "Next: Review",
|
||||
saveDraft: "Save as Draft",
|
||||
autoSaved: "Auto-saved",
|
||||
},
|
||||
review: {
|
||||
title: "Product Summary",
|
||||
subtitle: "Review before submitting",
|
||||
breadcrumbProducts: "Products",
|
||||
breadcrumbEditor: "Editor",
|
||||
category: "Category",
|
||||
subCategory: "Sub Category",
|
||||
productName: "Product Name",
|
||||
description: "Description",
|
||||
preOrder: "Pre-order",
|
||||
brandNew: "Brand New",
|
||||
keywords: "Keywords",
|
||||
features: "Product Features",
|
||||
mainImage: "main image uploaded",
|
||||
gallery: "gallery image",
|
||||
price: "Price",
|
||||
weight: "Weight",
|
||||
dimensions: "Dimensions",
|
||||
warehouseStock: "Warehouse Stock",
|
||||
measurements: "Measurements / Variants",
|
||||
productInfo: "Product Information",
|
||||
categoryInfo: "Category Information",
|
||||
compliance: "Compliance",
|
||||
warranty: "Warranty",
|
||||
countryOfOrigin: "Country of Origin",
|
||||
safetyWarning: "Safety Warning",
|
||||
dangerousGoods: "Dangerous Goods",
|
||||
supportingDocs: "Supporting Documents",
|
||||
msds: "MSDS Document",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
preOrderDay: "Pre-order Day",
|
||||
eligibleToExport: "Eligible to Export",
|
||||
section01: "Category",
|
||||
section02: "Product Details",
|
||||
section03: "Pricing & Model",
|
||||
section04: "General Info",
|
||||
section05: "Supporting Documents",
|
||||
model: "model",
|
||||
unit: "unit",
|
||||
promoPrice: "Promo Price",
|
||||
promoPeriod: "Promo Period",
|
||||
packagingWeight: "Packaging Weight",
|
||||
packagingDimensions: "Packaging Dimensions",
|
||||
stock: "Stock",
|
||||
variantLabel: "Variant",
|
||||
warrantyType: "Warranty Type",
|
||||
warrantyDuration: "Duration",
|
||||
submitting: "Submitting...",
|
||||
copied: "Copied!",
|
||||
copyErrorLog: "Copy Error Log",
|
||||
saving: "Saving product...",
|
||||
autoSaved: "Auto-saved",
|
||||
saveDraft: "Save Draft",
|
||||
back: "Back",
|
||||
submit: "Submit for Review",
|
||||
},
|
||||
},
|
||||
productDetail: {
|
||||
breadcrumbProducts: "Products",
|
||||
breadcrumbEditor: "Editor",
|
||||
title: "Product Detail",
|
||||
editProduct: "Edit Product",
|
||||
back: "Back",
|
||||
modeReadOnly: "Mode: Read-Only",
|
||||
loading: "Loading product...",
|
||||
errorLoad: "Failed to load product",
|
||||
notFound: "Product data not found.",
|
||||
section01: "Basic Details",
|
||||
section02: "Description",
|
||||
section03: "Pricing & Model",
|
||||
section04: "General Info",
|
||||
mainCategory: "Main Category",
|
||||
subCategory: "Sub Category",
|
||||
officialName: "Official Product Name",
|
||||
preOrder: "Pre-order Item",
|
||||
brandNew: "Brand New",
|
||||
preOrderDay: "Pre-order Day",
|
||||
keywords: "Search Keywords",
|
||||
narrative: "Product Narrative",
|
||||
features: "Key Product Features",
|
||||
visualIdentity: "Visual Identity",
|
||||
mainImage: "Main Image",
|
||||
gallery: "Gallery",
|
||||
imagesAvailable: "images available for this product.",
|
||||
price: "Price",
|
||||
warehouseStock: "Warehouse Stock",
|
||||
measurements: "Measurements / Variants",
|
||||
productInfo: "Product Information",
|
||||
categoryInfo: "Category Information",
|
||||
compliance: "Compliance",
|
||||
countryOfOrigin: "Country of Origin",
|
||||
safetyWarning: "Safety Warning",
|
||||
dangerousGoods: "Dangerous Goods",
|
||||
warranty: "Warranty",
|
||||
warrantyDuration: "Duration",
|
||||
export: "Export",
|
||||
eligibleToExport: "Eligible to Export",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
stock: "stock",
|
||||
},
|
||||
productEdit: {
|
||||
breadcrumbProducts: "Products",
|
||||
breadcrumbEditor: "Editor",
|
||||
breadcrumbEdit: "Edit Listing",
|
||||
title: "Edit Product",
|
||||
cancel: "Cancel",
|
||||
saving: "Saving...",
|
||||
saveChanges: "Save Changes",
|
||||
saveDraft: "Save Draft",
|
||||
submitProduct: "Submit Product",
|
||||
publishing: "Processing...",
|
||||
successUpdate: "Product updated and sent for review.",
|
||||
successDraft: "Draft saved.",
|
||||
successPublish: "Product published successfully.",
|
||||
errorGeneric: "Failed to save product",
|
||||
errorLoad: "Failed to load data",
|
||||
productNotFound: "Product data not found.",
|
||||
draftAutoSaved: "Draft Auto-saved",
|
||||
section01: "Basic Details",
|
||||
section02: "Description",
|
||||
section03: "Pricing & Model",
|
||||
section04: "General Info",
|
||||
mainCategory: "Main Category",
|
||||
subCategory: "Sub Category",
|
||||
categoryLocked: "Category cannot be changed after product is created.",
|
||||
officialName: "Official Product Name",
|
||||
preOrder: "Pre-order Item",
|
||||
preOrderSub: "Ships in 2-3 weeks",
|
||||
brandNew: "Brand New",
|
||||
brandNewSub: "Original packaging",
|
||||
preOrderDay: "Pre-order Day",
|
||||
keywords: "Search Keywords",
|
||||
addKeyword: "Add keyword",
|
||||
add: "Add",
|
||||
narrative: "Product Narrative",
|
||||
narrativePlaceholder: "Describe the craftsmanship, materials, and value proposition...",
|
||||
features: "Key Product Features",
|
||||
addFeature: "Add Feature",
|
||||
visualIdentity: "Visual Identity",
|
||||
addImage: "Add Image",
|
||||
addModel: "Add Model",
|
||||
productInfo: "Product Information",
|
||||
categoryInfo: "Category Information",
|
||||
addRow: "Add Row",
|
||||
compliance: "Compliance",
|
||||
warranty: "Warranty",
|
||||
countryOfOrigin: "Country of Origin",
|
||||
safetyWarning: "Safety Warning",
|
||||
dangerousGoods: "Hazardous (Dangerous Goods)?",
|
||||
eligibleToExport: "Eligible to Export",
|
||||
warrantyType: "Warranty Type",
|
||||
warrantyDuration: "Duration",
|
||||
warrantyDurationType: "Duration Type",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
loading: "Loading product...",
|
||||
selectMainCategory: "Select main category...",
|
||||
selectSubCategory: "Select sub category...",
|
||||
selectMainCategoryFirst: "Select main category first...",
|
||||
loadingSubcategories: "Loading...",
|
||||
mainImage: "Main Image",
|
||||
gallery: "Gallery",
|
||||
addWarehouse: "Add Warehouse",
|
||||
selectWarehouse: "Select warehouse...",
|
||||
stock: "Stock",
|
||||
modelImage: "Model Image",
|
||||
price: "Price",
|
||||
currency: "Currency",
|
||||
weight: "Weight",
|
||||
weightType: "Weight Type",
|
||||
dims: "Dimensions (L / W / H)",
|
||||
dimsShort: "Dims (L / W / H)",
|
||||
promotion: "Promotion",
|
||||
promoPrice: "Promo Price",
|
||||
promoCurrency: "Promo Currency",
|
||||
startDate: "Start Date",
|
||||
endDate: "End Date",
|
||||
packagingFootprint: "Packaging Footprint",
|
||||
packaging: "Packaging",
|
||||
pkgWeight: "Pkg Weight",
|
||||
pkgDimsShort: "Pkg Dims (L / W / H)",
|
||||
warehouseStock: "Warehouse Stock",
|
||||
measurementsVariants: "Measurements / Variants",
|
||||
addMeasurement: "Add Measurement",
|
||||
measurementLabel: "Measurement",
|
||||
variantAdded: "variant(s) added",
|
||||
addSizeVariant: "Add size/color/type variants for this model",
|
||||
coreInformation: "Core Information",
|
||||
pricingSpecs: "Pricing & Physical Specs",
|
||||
measurementTypeLabel: "Measurement Type",
|
||||
measurementValueLabel: "Measurement Value",
|
||||
fieldName: "Field name",
|
||||
fieldValue: "Value",
|
||||
deleteModel: "Delete",
|
||||
uploaded: "Uploaded",
|
||||
noImage: "No image",
|
||||
changeImage: "Change Image",
|
||||
uploadModelImage: "Upload Model Image",
|
||||
uploading: "Uploading...",
|
||||
},
|
||||
overview: {
|
||||
title: "Executive Overview",
|
||||
subtitle: "Real-time performance metrics for the Export Division.",
|
||||
totalProducts: "Total Products",
|
||||
totalBuyers: "Total Buyers",
|
||||
refunds: "Refunds",
|
||||
vsLastMonth: "vs last month",
|
||||
globalReach: "Global reach active",
|
||||
returnRate: "return rate",
|
||||
ordersAnalytics: "Orders Analytics",
|
||||
ordersSubtitle: "Daily order frequency and fulfillment cycles",
|
||||
last30Days: "Last 30 Days",
|
||||
lastQuarter: "Last Quarter",
|
||||
earnings: "Earnings",
|
||||
grossRevenue: "Gross Revenue",
|
||||
directSales: "Direct Sales",
|
||||
retailPartners: "Retail Partners",
|
||||
affiliates: "Affiliates",
|
||||
recentOrders: "Recent Orders",
|
||||
viewAll: "View All Operations",
|
||||
productDetails: "Product Details",
|
||||
customer: "Customer",
|
||||
transactionDate: "Transaction Date",
|
||||
amount: "Amount",
|
||||
status: "Status",
|
||||
action: "Action",
|
||||
wk: "WK",
|
||||
},
|
||||
},
|
||||
};
|
||||
744
src/lib/translations/id.ts
Normal file
744
src/lib/translations/id.ts
Normal file
@ -0,0 +1,744 @@
|
||||
export const id = {
|
||||
common: {
|
||||
loading: "Memproses...",
|
||||
connectionError: "Koneksi gagal, coba lagi",
|
||||
cancel: "Batal",
|
||||
or: "atau",
|
||||
help: "Bantuan",
|
||||
privacy: "Kebijakan Privasi",
|
||||
terms: "Syarat & Ketentuan",
|
||||
logout: "Keluar",
|
||||
uploaded: "Terupload",
|
||||
delete: "Hapus",
|
||||
back: "Kembali",
|
||||
},
|
||||
auth: {
|
||||
login: {
|
||||
title: "Selamat Datang",
|
||||
subtitle: "Masuk ke dashboard bisnis Anda.",
|
||||
emailOrPhone: "Email atau Nomor HP",
|
||||
password: "Password",
|
||||
forgotPassword: "Lupa password?",
|
||||
rememberDevice: "Ingat perangkat ini selama 30 hari",
|
||||
submit: "Masuk",
|
||||
submitting: "Memproses...",
|
||||
noAccount: "Belum punya akun?",
|
||||
registerFree: "Daftar gratis",
|
||||
errorGeneric: "Terjadi kesalahan, coba lagi",
|
||||
errorConnection: "Koneksi gagal, coba lagi",
|
||||
marketStatus: "PASAR GLOBAL BUKA",
|
||||
heroTitle: "Akses\nPasar\nGlobal",
|
||||
heroSubtitle:
|
||||
"Memberdayakan usaha lokal dengan kecerdasan finansial institusional dan konektivitas logistik global.",
|
||||
successRate: "Tingkat Sukses",
|
||||
marketAccess: "Akses Pasar",
|
||||
countries: "Negara",
|
||||
},
|
||||
register: {
|
||||
title: "Buat Akun",
|
||||
subtitle: "Mulai perjalanan trading Anda hari ini",
|
||||
joinNetwork: "Bergabung dengan\nJaringan Global.",
|
||||
joinSubtitle:
|
||||
"Memberdayakan pedagang UMKM dengan metrik finansial real-time dan wawasan data editorial bergengsi.",
|
||||
registerAs: "Daftar sebagai",
|
||||
sellerDesc: "Jual produk ke pasar global",
|
||||
buyerDesc: "Beli produk UMKM Indonesia",
|
||||
email: "Email",
|
||||
emailFromPrevious: "Email dari proses sebelumnya, tidak dapat diubah",
|
||||
fullName: "Nama Lengkap",
|
||||
phone: "Nomor HP",
|
||||
password: "Password",
|
||||
passwordPlaceholder: "Minimal 6 karakter",
|
||||
confirmPassword: "Konfirmasi Password",
|
||||
confirmPasswordPlaceholder: "Ulangi password",
|
||||
submit: "Verifikasi Email",
|
||||
submitting: "Mengirim OTP...",
|
||||
haveAccount: "Sudah punya akun?",
|
||||
signIn: "Masuk",
|
||||
passwordMismatch: "Password tidak cocok",
|
||||
passwordTooShort: "Password minimal 6 karakter",
|
||||
otpError: "Gagal mengirim OTP, coba lagi",
|
||||
stepOf: "Langkah 01 dari 03",
|
||||
termsAgreement:
|
||||
"Dengan membuat akun, Anda menyetujui",
|
||||
and: "dan",
|
||||
inaTrading: "Ina Trading.",
|
||||
successRate: "Tingkat Sukses",
|
||||
marketPulse: "Denyut Pasar",
|
||||
},
|
||||
verify: {
|
||||
title: "Verifikasi email",
|
||||
subtitle: "Kami telah mengirim kode 6 digit ke",
|
||||
subtitleSuffix: ". Masukkan kode tersebut di bawah ini.",
|
||||
noCode: "Tidak menerima kode?",
|
||||
resend: "Kirim Ulang OTP",
|
||||
resending: "Mengirim...",
|
||||
resendCountdown: "Kirim ulang",
|
||||
submit: "Verifikasi OTP",
|
||||
submitting: "Memverifikasi...",
|
||||
otpTooShort: "Masukkan 6 digit kode OTP",
|
||||
noData: "Data registrasi tidak ditemukan, silakan ulangi pendaftaran",
|
||||
verifyFail: "Verifikasi gagal",
|
||||
registerFail: "Registrasi seller gagal",
|
||||
successSeller:
|
||||
"OTP valid dan akun seller berhasil dibuat. Mengalihkan ke data bisnis...",
|
||||
successBuyer:
|
||||
"OTP berhasil diverifikasi. Mengalihkan ke langkah berikutnya...",
|
||||
securityTitle: "Keamanan Tingkat Institusional",
|
||||
securityDesc:
|
||||
"Dengan melanjutkan, Anda menyetujui Syarat & Ketentuan dan Kebijakan Privasi Ina Trading. Data Anda dilindungi dengan enkripsi tingkat lanjut.",
|
||||
secureYourFuture: "AMANKAN\nMASA\nDEPAN ANDA.",
|
||||
transactionSecurity: "Keamanan Transaksi",
|
||||
bankLevelEncryption: "Enkripsi Bank-Level",
|
||||
verifyIdentity:
|
||||
"Verifikasi identitas Anda adalah langkah pertama menuju penguasaan finansial global.",
|
||||
},
|
||||
complete: {
|
||||
title: "Finalisasi Registrasi",
|
||||
subtitle: "OTP sudah valid. Akun akan dibuat pada langkah terakhir ini.",
|
||||
submit: "Buat Akun Ina Trading",
|
||||
submitting: "Memproses...",
|
||||
noData: "Data registrasi tidak ditemukan",
|
||||
registerFail: "Registrasi gagal",
|
||||
backToRegister: "Kembali ke registrasi",
|
||||
finalStep: "Langkah Terakhir",
|
||||
},
|
||||
forgotPassword: {
|
||||
title: "Lupa password",
|
||||
subtitle:
|
||||
"Masukkan alamat email atau nomor HP terdaftar Anda untuk menerima tautan reset yang aman.",
|
||||
emailOrPhone: "Email atau Nomor HP",
|
||||
submit: "Kirim",
|
||||
backToLogin: "Kembali ke login",
|
||||
havingTrouble: "Ada masalah?",
|
||||
supportLink: "Hubungi tim support 24/7 kami.",
|
||||
securityFirst: "KEAMANAN UTAMA",
|
||||
heroTitle: "Melindungi\nPertumbuhan Anda.",
|
||||
heroSubtitle:
|
||||
"Di Ina Trading, kami memperlakukan metrik bisnis Anda dengan presisi global. Mari kembali ke dashboard Anda.",
|
||||
uptime: "Ketersediaan",
|
||||
fraudMonitoring: "Pemantauan Fraud",
|
||||
apiNotReady:
|
||||
"API reset password belum tersedia. Layar ini sudah siap dan permintaan berhasil disimulasikan untuk",
|
||||
},
|
||||
accountNotFound: {
|
||||
title: "Akun tidak ditemukan",
|
||||
change: "Ganti?",
|
||||
createAccount: "Buat Akun",
|
||||
loginOther: "Masuk dengan email atau nomor lain",
|
||||
disclaimer:
|
||||
"Ina Trading adalah entitas perdagangan terregulasi. Aset digital dan instrumen perdagangan global membawa risiko signifikan. Pastikan Anda telah membaca pengungkapan regulasi kami sebelum membuat akun.",
|
||||
editorialIntelligence: "Kecerdasan Editorial",
|
||||
tradePrecision: "BERDAGANG\nDENGAN PRESISI.",
|
||||
tradeSubtitle:
|
||||
"Akses alat pasar berkelas institusional dan wawasan rantai pasok global yang disesuaikan untuk operasi perdagangan modern.",
|
||||
marketStatus: "STATUS PASAR: AKTIF — VOLATILITAS RENDAH — BERDAGANG SEKARANG",
|
||||
helpLink: "Bantuan",
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
layout: {
|
||||
setupGuide: "Panduan Setup",
|
||||
progress: "Progress Onboarding",
|
||||
business: "Bisnis",
|
||||
storeDetail: "Store Detail",
|
||||
plan: "Paket",
|
||||
proTip: "Pro Tip",
|
||||
proTipText:
|
||||
"Verifikasi alamat bisnis Anda untuk membuka batas perdagangan global.",
|
||||
stepOf: "dari",
|
||||
},
|
||||
business: {
|
||||
pageTitle: "Lengkapi data",
|
||||
pageTitleHighlight: "bisnis",
|
||||
pageTitleSuffix: "Anda.",
|
||||
pageSubtitle:
|
||||
"Informasi ini digunakan untuk menyesuaikan dashboard dan profil kepatuhan Anda.",
|
||||
coreInfo: "Informasi Utama",
|
||||
regionalDetails: "Detail Regional",
|
||||
bankingInfo: "Informasi Perbankan",
|
||||
legalizationDocs: "Dokumen Legalitas",
|
||||
businessName: "Nama Bisnis",
|
||||
businessNamePlaceholder: "Nama Entitas Legal",
|
||||
businessAddress: "Alamat Bisnis",
|
||||
businessAddressPlaceholder: "Nama jalan, gedung, lantai...",
|
||||
country: "Negara",
|
||||
businessPhone: "Nomor HP Bisnis",
|
||||
province: "Provinsi / State",
|
||||
selectProvince: "Pilih Provinsi",
|
||||
provincePlaceholder: "Provinsi / State",
|
||||
city: "Kota / City",
|
||||
selectCity: "Pilih Kota",
|
||||
selectProvincFirst: "Pilih Provinsi dulu",
|
||||
loadingCities: "Memuat kota...",
|
||||
cityPlaceholder: "Kota",
|
||||
postalCode: "Kode Pos",
|
||||
bankName: "Nama Bank",
|
||||
selectBank: "Pilih Bank",
|
||||
bankNamePlaceholder: "Nama bank",
|
||||
accountNumber: "Nomor Rekening",
|
||||
uploadNew: "Upload Baru",
|
||||
docsNote:
|
||||
"Harap sediakan file pendukung seperti NIB, NPWP, atau Akta Pendirian.",
|
||||
uploadHint: "Drag & Drop atau Klik untuk Tambah Dokumen",
|
||||
docNoRequired: "Harap upload minimal 1 dokumen legalitas",
|
||||
saveError: "Gagal menyimpan data bisnis",
|
||||
genericError: "Terjadi kesalahan",
|
||||
submitting: "Menyimpan...",
|
||||
submit: "Lanjutkan",
|
||||
logout: "Logout",
|
||||
failLoadProvinces: "Gagal memuat daftar provinsi",
|
||||
failLoadCities: "Gagal memuat kota",
|
||||
uploadFail: "Upload gagal",
|
||||
sessionExpired: "Sesi login sudah habis. Silakan login ulang sebelum upload dokumen.",
|
||||
globalReach: "Global Reach,\nLocal Roots.",
|
||||
complianceNote: "Catatan Kepatuhan",
|
||||
complianceDesc:
|
||||
"Pastikan nama bisnis sesuai dengan dokumen registrasi resmi untuk verifikasi yang lebih cepat.",
|
||||
docDetail: "Detail Dokumen",
|
||||
docType: "Jenis Dokumen",
|
||||
otherDocType: "Jenis Dokumen Lainnya",
|
||||
otherDocTypePlaceholder: "Tulis jenis dokumen",
|
||||
docNumber: "Nomor Dokumen",
|
||||
docNumberPlaceholder: "Nomor dokumen",
|
||||
publishedDate: "Tanggal Terbit",
|
||||
validDate: "Tanggal Berlaku",
|
||||
docDetailRequired: "Harap lengkapi seluruh detail dokumen terlebih dahulu",
|
||||
addDocument: "Tambah Dokumen",
|
||||
uploading: "Mengupload...",
|
||||
uploadMissingFileId: "Upload tidak menghasilkan fileId dari backend",
|
||||
},
|
||||
storeDetail: {
|
||||
title: "Store Configuration",
|
||||
subtitle:
|
||||
"Finalisasi storefront digital dan hub logistik Anda. Informasi ini akan terlihat oleh partner institusional dan penyedia logistik.",
|
||||
storeInfo: "Store Information",
|
||||
storeName: "Store Name",
|
||||
storeNamePlaceholder: "Masukkan nama toko",
|
||||
storeBiography: "Store Biography",
|
||||
storeBiographyPlaceholder: "Ceritakan toko Anda...",
|
||||
storePhoto: "Store Photo",
|
||||
storePhotoHelper: "Upload foto toko atau logo storefront",
|
||||
warehouseSetup: "Warehouse Setup",
|
||||
primaryHub: "Primary Hub",
|
||||
warehouseName: "Warehouse Name",
|
||||
warehouseNamePlaceholder: "Masukkan nama gudang",
|
||||
fullAddress: "Full Address",
|
||||
fullAddressPlaceholder: "Masukkan alamat lengkap gudang",
|
||||
warehouseType: "Warehouse Type",
|
||||
country: "Country",
|
||||
province: "Province",
|
||||
city: "City",
|
||||
postalCode: "Postal Code",
|
||||
latitude: "Latitude",
|
||||
longitude: "Longitude",
|
||||
other: "Other",
|
||||
back: "Kembali ke Informasi Bisnis",
|
||||
termsNote:
|
||||
"Dengan melanjutkan, Anda menyetujui ketentuan layanan institusional kami.",
|
||||
continue: "Lanjutkan",
|
||||
saving: "Menyimpan...",
|
||||
requiredError: "Harap lengkapi detail store dan warehouse terlebih dahulu",
|
||||
genericError: "Terjadi kesalahan saat menyimpan store detail",
|
||||
},
|
||||
plan: {
|
||||
step: "Langkah 03 dari 03",
|
||||
title: "Pilih mesin pertumbuhan Anda",
|
||||
subtitle:
|
||||
"Pilih paket langganan yang sesuai dengan skala UMKM Anda. Tanpa biaya tersembunyi, hanya kekuatan trading berkelas institusional.",
|
||||
selected: "Dipilih",
|
||||
currentStep: "Langkah Saat Ini",
|
||||
selectPlan: "Pilih Paket",
|
||||
contactUs: "Hubungi Kami",
|
||||
back: "Kembali",
|
||||
skip: "Lewati untuk sekarang",
|
||||
submitSelection: "Konfirmasi Pilihan",
|
||||
testimonial:
|
||||
'"Paket Professional memberi UMKM kami data likuiditas yang kami butuhkan untuk berkembang tiga kali lebih cepat dari yang diharapkan."',
|
||||
testimonialAuthor: "Hendra Wijaya, CEO TaniTrade",
|
||||
userSuccessRate: "Tingkat Sukses Pengguna",
|
||||
mostPopular: "Paling Populer",
|
||||
features: {
|
||||
basicAnalytics: "Analitik Dasar",
|
||||
upTo10Trades: "Hingga 10 Perdagangan",
|
||||
apiAccess: "Akses API",
|
||||
advancedReports: "Laporan Lanjutan",
|
||||
unlimitedTrades: "Perdagangan Tak Terbatas",
|
||||
exportToExcel: "Ekspor ke Excel",
|
||||
fullApiAccess: "Akses API Penuh",
|
||||
customDashboards: "Dashboard Kustom",
|
||||
teamManagement: "Manajemen Tim",
|
||||
slaGuarantee: "Jaminan SLA",
|
||||
dedicatedManager: "Manager Khusus",
|
||||
customIntegrations: "Integrasi Kustom",
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
layout: {
|
||||
searchPlaceholder: "Cari operasi...",
|
||||
editorial: "EDITORIAL",
|
||||
globalExportHub: "Pusat Ekspor Global",
|
||||
helpCenter: "Pusat Bantuan",
|
||||
exportReport: "EKSPOR LAPORAN",
|
||||
nav: {
|
||||
Dashboard: "Dashboard",
|
||||
Product: "Produk",
|
||||
Warehouse: "Gudang",
|
||||
Orders: "Pesanan",
|
||||
Invoice: "Tagihan",
|
||||
Settings: "Pengaturan",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
account: "Akun",
|
||||
profile: "Profil",
|
||||
changePassword: "Ubah Kata Sandi",
|
||||
sellerProfile: "Profil Penjual",
|
||||
management: "Manajemen",
|
||||
profileConfiguration: "Konfigurasi Profil",
|
||||
editProfile: "Edit Profil",
|
||||
storeName: "Nama Toko",
|
||||
storeBiography: "Biografi Toko",
|
||||
storePhoto: "Foto Toko",
|
||||
storeInfo: "Informasi Toko",
|
||||
sellerId: "ID Penjual",
|
||||
viewStorefront: "Lihat Toko",
|
||||
saveChanges: "Simpan Perubahan",
|
||||
saving: "Menyimpan...",
|
||||
successUpdate: "Profil berhasil diperbarui.",
|
||||
errorLoad: "Gagal memuat profil",
|
||||
errorSave: "Gagal menyimpan profil",
|
||||
profileNotFound: "Data profil tidak ditemukan.",
|
||||
complianceNote: "Perubahan akan ditinjau oleh tim INA Marketplace Compliance sebelum dipublikasikan.",
|
||||
loading: "Memuat profil...",
|
||||
},
|
||||
changePassword: {
|
||||
title: "Ubah Kata Sandi",
|
||||
settings: "Pengaturan",
|
||||
security: "Keamanan",
|
||||
currentPassword: "Password Saat Ini",
|
||||
newPassword: "Password Baru",
|
||||
confirmNewPassword: "Konfirmasi Password Baru",
|
||||
updatePassword: "Perbarui Password",
|
||||
cancel: "Batal",
|
||||
saving: "Menyimpan...",
|
||||
successMessage: "Password berhasil diperbarui.",
|
||||
errorRequired: "Semua field harus diisi.",
|
||||
errorMismatch: "Konfirmasi password tidak cocok.",
|
||||
errorWeak: "Password baru terlalu lemah. Gunakan minimal 8 karakter dengan kombinasi huruf dan angka.",
|
||||
errorGeneric: "Gagal mengubah password",
|
||||
mismatch: "Password tidak cocok",
|
||||
strengthPrefix: "KEKUATAN:",
|
||||
strengthWeak: "LEMAH",
|
||||
strengthModerate: "SEDANG",
|
||||
strengthStrong: "KUAT",
|
||||
strengthVeryStrong: "SANGAT KUAT",
|
||||
guidelines: {
|
||||
title: "Panduan\nKeamanan",
|
||||
length: "Persyaratan Panjang",
|
||||
lengthDesc: "Minimal 8 karakter. Kompleksitas meningkat dengan setiap karakter tambahan.",
|
||||
mix: "Kombinasi Karakter",
|
||||
mixDesc: "Sertakan minimal satu huruf besar, satu angka, dan satu simbol unik.",
|
||||
entropy: "Entropy Unik",
|
||||
entropyDesc: "Hindari mengulang password sebelumnya atau menggunakan kata kamus umum.",
|
||||
lastChanged: "Terakhir Diubah: baru-baru ini",
|
||||
},
|
||||
},
|
||||
products: {
|
||||
title: "Katalog Produk",
|
||||
management: "Manajemen",
|
||||
catalog: "Manajemen Katalog",
|
||||
addProduct: "Tambah Produk",
|
||||
totalItems: "Total Item Dikelola",
|
||||
activeCatalog: "Katalog Aktif",
|
||||
marketSplit: "Pembagian Pasar:",
|
||||
marketSplitDesc: "Distribusi katalog pasar internasional vs lokal.",
|
||||
activeView: "Tampilan Aktif",
|
||||
loading: "Memuat produk...",
|
||||
empty: "Tidak ada produk ditemukan.",
|
||||
edit: "Edit",
|
||||
detail: "Detail",
|
||||
table: {
|
||||
product: "Produk",
|
||||
price: "Harga",
|
||||
stock: "Stok",
|
||||
market: "Pasar",
|
||||
action: "Aksi",
|
||||
showing: "Menampilkan",
|
||||
of: "dari",
|
||||
products: "produk",
|
||||
},
|
||||
deleteDialog: {
|
||||
title: "Hapus Produk?",
|
||||
message: "Tindakan ini tidak dapat dibatalkan.",
|
||||
productLabel: "Produk yang akan dihapus",
|
||||
cancel: "Batal",
|
||||
confirm: "Ya, Hapus",
|
||||
deleting: "Menghapus...",
|
||||
errorGeneric: "Gagal menghapus produk",
|
||||
},
|
||||
tabs: {
|
||||
allProduct: "Semua Produk",
|
||||
draft: "Draft",
|
||||
inReview: "Dalam Tinjauan",
|
||||
internationalMarket: "Pasar Internasional",
|
||||
localMarket: "Pasar Lokal",
|
||||
outOfStock: "Habis Stok",
|
||||
rejected: "Ditolak",
|
||||
},
|
||||
},
|
||||
productNew: {
|
||||
layout: {
|
||||
breadcrumbProducts: "Produk",
|
||||
breadcrumbEditor: "Editor",
|
||||
breadcrumbNew: "Listing Baru",
|
||||
pageTitle: "Post Produk: Listing Baru",
|
||||
pageSubtitle: "Tentukan produk Anda langkah demi langkah. Semua progres disimpan sebagai draft hingga Anda submit untuk tinjauan.",
|
||||
cancel: "Batal",
|
||||
stepLabels: ["Detail Dasar", "Deskripsi", "Harga & Model", "Info Umum", "Tinjauan"],
|
||||
},
|
||||
category: {
|
||||
title: "Listing Produk",
|
||||
subtitle: "Presisi dimulai dari taksonomi yang tepat.",
|
||||
mainCategory: "01. Kategori Utama",
|
||||
subCategory: "02. Sub Kategori",
|
||||
loadingCategories: "Memuat kategori...",
|
||||
selectMain: "Pilih departemen utama...",
|
||||
awaitingMain: "Menunggu pilihan kategori utama...",
|
||||
loadingSubcategories: "Memuat sub kategori...",
|
||||
selectSub: "Pilih sub kategori...",
|
||||
saveDraft: "Simpan Draft",
|
||||
next: "Berikutnya: Deskripsi",
|
||||
cancel: "Batal",
|
||||
autoSaved: "Tersimpan otomatis",
|
||||
errorLoadCat: "Gagal memuat kategori",
|
||||
errorLoadSub: "Gagal memuat sub kategori",
|
||||
},
|
||||
details: {
|
||||
title: "Deskripsi",
|
||||
officialName: "Nama Resmi Produk",
|
||||
officialNamePlaceholder: "mis. Kursi Eksekutif Kulit Italia Vintage",
|
||||
preOrder: "Item Pre-order",
|
||||
preOrderSub: "Dikirim dalam 2-3 minggu",
|
||||
brandNew: "Baru",
|
||||
brandNewSub: "Kemasan asli",
|
||||
preOrderDay: "Hari Pre-order",
|
||||
keywords: "Kata Kunci Pencarian",
|
||||
addKeyword: "Tambah kata kunci",
|
||||
addKeywordBtn: "Tambah",
|
||||
narrative: "Narasi Produk",
|
||||
narrativePlaceholder: "Deskripsikan keahlian, bahan, dan nilai produk...",
|
||||
features: "Fitur Utama Produk",
|
||||
addFeature: "Tambah Fitur",
|
||||
noFeatures: "Belum ada fitur ditambahkan.",
|
||||
visualIdentity: "Identitas Visual",
|
||||
addImage: "Tambah Gambar",
|
||||
noImages: "Belum ada gambar ditambahkan.",
|
||||
back: "Kembali",
|
||||
next: "Berikutnya: Harga",
|
||||
saveDraft: "Simpan Draft",
|
||||
autoSaved: "Tersimpan otomatis",
|
||||
},
|
||||
pricing: {
|
||||
title: "Harga & Model",
|
||||
addModel: "Tambah Model",
|
||||
modelName: "Nama Model",
|
||||
sku: "SKU",
|
||||
price: "Harga",
|
||||
currency: "Mata Uang",
|
||||
weight: "Berat",
|
||||
weightUnit: "Satuan Berat",
|
||||
length: "Panjang",
|
||||
width: "Lebar",
|
||||
height: "Tinggi",
|
||||
dimensionUnit: "Satuan Dimensi",
|
||||
hasPromotion: "Atur Harga Promo",
|
||||
promotionPrice: "Harga Promo",
|
||||
promotionCurrency: "Mata Uang Promo",
|
||||
promotionStart: "Tanggal Mulai Promo",
|
||||
promotionEnd: "Tanggal Akhir Promo",
|
||||
packagingWeight: "Berat Kemasan",
|
||||
packagingLength: "Panjang Kemasan",
|
||||
packagingWidth: "Lebar Kemasan",
|
||||
packagingHeight: "Tinggi Kemasan",
|
||||
selectWarehouse: "Pilih Gudang",
|
||||
stock: "Stok",
|
||||
addWarehouse: "Tambah Stok Gudang",
|
||||
removeWarehouse: "Hapus",
|
||||
measurement: "Pengukuran / Varian",
|
||||
addMeasurement: "Tambah Pengukuran",
|
||||
measurementType: "Tipe Pengukuran",
|
||||
measurementValue: "Nilai Pengukuran",
|
||||
noModels: "Belum ada model ditambahkan.",
|
||||
removeModel: "Hapus Model",
|
||||
removeMeasurement: "Hapus Pengukuran",
|
||||
uploadImage: "Upload Gambar Model",
|
||||
changeImage: "Ganti Gambar",
|
||||
imageUploaded: "Gambar Terupload",
|
||||
uploading: "Mengupload...",
|
||||
back: "Kembali",
|
||||
next: "Berikutnya: Spesifikasi",
|
||||
saveDraft: "Simpan Draft",
|
||||
autoSaved: "Tersimpan otomatis",
|
||||
uploadFail: "Upload gagal",
|
||||
},
|
||||
specifications: {
|
||||
title: "Spesifikasi",
|
||||
generalInfo: "Informasi Umum",
|
||||
categoryInfo: "Informasi Kategori",
|
||||
compliance: "Kepatuhan",
|
||||
warranty: "Garansi",
|
||||
paramName: "Nama Parameter",
|
||||
paramValue: "Nilai Parameter",
|
||||
addRow: "Tambah Baris",
|
||||
countryOfOrigin: "Negara Asal",
|
||||
safetyWarning: "Peringatan Keamanan",
|
||||
dangerousGoods: "Barang Berbahaya?",
|
||||
yes: "Ya",
|
||||
no: "Tidak",
|
||||
msds: "Dokumen MSDS",
|
||||
supportingDocs: "Dokumen Pendukung",
|
||||
warrantyType: "Tipe Garansi",
|
||||
warrantyDuration: "Durasi",
|
||||
warrantyDurationType: "Tipe Durasi",
|
||||
eligibleToExport: "Memenuhi Syarat Ekspor",
|
||||
awaitingCategory: "Pilih sub-kategori pada langkah pertama terlebih dahulu...",
|
||||
uploadFail: "Upload gagal",
|
||||
back: "Kembali",
|
||||
next: "Berikutnya: Tinjauan",
|
||||
saveDraft: "Simpan Draft",
|
||||
autoSaved: "Tersimpan otomatis",
|
||||
},
|
||||
review: {
|
||||
title: "Ringkasan Produk",
|
||||
subtitle: "Periksa kembali sebelum submit",
|
||||
breadcrumbProducts: "Produk",
|
||||
breadcrumbEditor: "Editor",
|
||||
category: "Kategori",
|
||||
subCategory: "Sub Kategori",
|
||||
productName: "Nama Produk",
|
||||
description: "Deskripsi",
|
||||
preOrder: "Pre-order",
|
||||
brandNew: "Baru",
|
||||
keywords: "Kata Kunci",
|
||||
features: "Fitur Produk",
|
||||
mainImage: "gambar utama terupload",
|
||||
gallery: "gambar galeri",
|
||||
price: "Harga",
|
||||
weight: "Berat",
|
||||
dimensions: "Dimensi",
|
||||
warehouseStock: "Stok Gudang",
|
||||
measurements: "Pengukuran / Varian",
|
||||
productInfo: "Informasi Produk",
|
||||
categoryInfo: "Informasi Kategori",
|
||||
compliance: "Kepatuhan",
|
||||
warranty: "Garansi",
|
||||
countryOfOrigin: "Negara Asal",
|
||||
safetyWarning: "Peringatan Keamanan",
|
||||
dangerousGoods: "Barang Berbahaya",
|
||||
supportingDocs: "Dokumen Pendukung",
|
||||
msds: "Dokumen MSDS",
|
||||
yes: "Ya",
|
||||
no: "Tidak",
|
||||
preOrderDay: "Hari Pre-order",
|
||||
eligibleToExport: "Memenuhi Syarat Ekspor",
|
||||
section01: "Kategori",
|
||||
section02: "Detail Produk",
|
||||
section03: "Harga & Model",
|
||||
section04: "Info Umum",
|
||||
section05: "Dokumen Pendukung",
|
||||
model: "model",
|
||||
unit: "unit",
|
||||
promoPrice: "Harga Promo",
|
||||
promoPeriod: "Periode Promo",
|
||||
packagingWeight: "Berat Kemasan",
|
||||
packagingDimensions: "Dimensi Kemasan",
|
||||
stock: "Stok",
|
||||
variantLabel: "Varian",
|
||||
warrantyType: "Tipe Garansi",
|
||||
warrantyDuration: "Durasi",
|
||||
submitting: "Mengirim...",
|
||||
copied: "Tersalin!",
|
||||
copyErrorLog: "Salin Log Error",
|
||||
saving: "Menyimpan produk...",
|
||||
autoSaved: "Tersimpan otomatis",
|
||||
saveDraft: "Simpan Draft",
|
||||
back: "Kembali",
|
||||
submit: "Submit untuk Ditinjau",
|
||||
},
|
||||
},
|
||||
productDetail: {
|
||||
breadcrumbProducts: "Produk",
|
||||
breadcrumbEditor: "Editor",
|
||||
title: "Detail Produk",
|
||||
editProduct: "Edit Produk",
|
||||
back: "Kembali",
|
||||
modeReadOnly: "Mode: Hanya Baca",
|
||||
loading: "Memuat produk...",
|
||||
errorLoad: "Gagal memuat produk",
|
||||
notFound: "Data produk tidak ditemukan.",
|
||||
section01: "Detail Dasar",
|
||||
section02: "Deskripsi",
|
||||
section03: "Harga & Model",
|
||||
section04: "Info Umum",
|
||||
mainCategory: "Kategori Utama",
|
||||
subCategory: "Sub Kategori",
|
||||
officialName: "Nama Resmi Produk",
|
||||
preOrder: "Item Pre-order",
|
||||
brandNew: "Baru",
|
||||
preOrderDay: "Hari Pre-order",
|
||||
keywords: "Kata Kunci Pencarian",
|
||||
narrative: "Narasi Produk",
|
||||
features: "Fitur Utama Produk",
|
||||
visualIdentity: "Identitas Visual",
|
||||
mainImage: "Gambar Utama",
|
||||
gallery: "Galeri",
|
||||
imagesAvailable: "gambar tersedia untuk produk ini.",
|
||||
price: "Harga",
|
||||
warehouseStock: "Stok Gudang",
|
||||
measurements: "Pengukuran / Varian",
|
||||
productInfo: "Informasi Produk",
|
||||
categoryInfo: "Informasi Kategori",
|
||||
compliance: "Kepatuhan",
|
||||
countryOfOrigin: "Negara Asal",
|
||||
safetyWarning: "Peringatan Keamanan",
|
||||
dangerousGoods: "Barang Berbahaya",
|
||||
warranty: "Garansi",
|
||||
warrantyDuration: "Durasi",
|
||||
export: "Ekspor",
|
||||
eligibleToExport: "Memenuhi Syarat Ekspor",
|
||||
yes: "Ya",
|
||||
no: "Tidak",
|
||||
stock: "stok",
|
||||
},
|
||||
productEdit: {
|
||||
breadcrumbProducts: "Produk",
|
||||
breadcrumbEditor: "Editor",
|
||||
breadcrumbEdit: "Edit Listing",
|
||||
title: "Edit Produk",
|
||||
cancel: "Batal",
|
||||
saving: "Menyimpan...",
|
||||
saveChanges: "Simpan Perubahan",
|
||||
saveDraft: "Simpan Draft",
|
||||
submitProduct: "Submit Produk",
|
||||
publishing: "Memproses...",
|
||||
successUpdate: "Produk berhasil diperbarui dan dikirim ke tinjauan.",
|
||||
successDraft: "Draft berhasil disimpan.",
|
||||
successPublish: "Produk berhasil dipublikasikan.",
|
||||
errorGeneric: "Gagal menyimpan produk",
|
||||
errorLoad: "Gagal memuat data",
|
||||
productNotFound: "Data produk tidak ditemukan.",
|
||||
draftAutoSaved: "Draft Tersimpan Otomatis",
|
||||
section01: "Detail Dasar",
|
||||
section02: "Deskripsi",
|
||||
section03: "Harga & Model",
|
||||
section04: "Info Umum",
|
||||
mainCategory: "Kategori Utama",
|
||||
subCategory: "Sub Kategori",
|
||||
categoryLocked: "Kategori tidak dapat diubah setelah produk dibuat.",
|
||||
officialName: "Nama Resmi Produk",
|
||||
preOrder: "Item Pre-order",
|
||||
preOrderSub: "Dikirim dalam 2-3 minggu",
|
||||
brandNew: "Baru",
|
||||
brandNewSub: "Kemasan asli",
|
||||
preOrderDay: "Hari Pre-order",
|
||||
keywords: "Kata Kunci Pencarian",
|
||||
addKeyword: "Tambah kata kunci",
|
||||
add: "Tambah",
|
||||
narrative: "Narasi Produk",
|
||||
narrativePlaceholder: "Deskripsikan keahlian, bahan, dan nilai produk...",
|
||||
features: "Fitur Utama Produk",
|
||||
addFeature: "Tambah Fitur",
|
||||
visualIdentity: "Identitas Visual",
|
||||
addImage: "Tambah Gambar",
|
||||
addModel: "Tambah Model",
|
||||
productInfo: "Informasi Produk",
|
||||
categoryInfo: "Informasi Kategori",
|
||||
addRow: "Tambah Baris",
|
||||
compliance: "Kepatuhan",
|
||||
warranty: "Garansi",
|
||||
countryOfOrigin: "Negara Asal",
|
||||
safetyWarning: "Peringatan Keamanan",
|
||||
dangerousGoods: "Barang Berbahaya?",
|
||||
eligibleToExport: "Memenuhi Syarat Ekspor",
|
||||
warrantyType: "Tipe Garansi",
|
||||
warrantyDuration: "Durasi",
|
||||
warrantyDurationType: "Tipe Durasi",
|
||||
yes: "Ya",
|
||||
no: "Tidak",
|
||||
loading: "Memuat produk...",
|
||||
selectMainCategory: "Pilih kategori utama...",
|
||||
selectSubCategory: "Pilih sub kategori...",
|
||||
selectMainCategoryFirst: "Pilih kategori utama dulu...",
|
||||
loadingSubcategories: "Memuat...",
|
||||
mainImage: "Gambar Utama",
|
||||
gallery: "Galeri",
|
||||
addWarehouse: "Tambah Gudang",
|
||||
selectWarehouse: "Pilih gudang...",
|
||||
stock: "Stok",
|
||||
modelImage: "Gambar Model",
|
||||
price: "Harga",
|
||||
currency: "Mata Uang",
|
||||
weight: "Berat",
|
||||
weightType: "Tipe Berat",
|
||||
dims: "Dimensi (P / L / T)",
|
||||
dimsShort: "Dim (P / L / T)",
|
||||
promotion: "Promosi",
|
||||
promoPrice: "Harga Promo",
|
||||
promoCurrency: "Mata Uang Promo",
|
||||
startDate: "Tanggal Mulai",
|
||||
endDate: "Tanggal Akhir",
|
||||
packagingFootprint: "Dimensi Kemasan",
|
||||
packaging: "Kemasan",
|
||||
pkgWeight: "Berat Kemasan",
|
||||
pkgDimsShort: "Dim Kemasan (P / L / T)",
|
||||
warehouseStock: "Stok Gudang",
|
||||
measurementsVariants: "Pengukuran / Varian",
|
||||
addMeasurement: "Tambah Pengukuran",
|
||||
measurementLabel: "Pengukuran",
|
||||
variantAdded: "varian ditambahkan",
|
||||
addSizeVariant: "Tambah varian ukuran/warna/tipe untuk model ini",
|
||||
coreInformation: "Informasi Inti",
|
||||
pricingSpecs: "Harga & Spesifikasi Fisik",
|
||||
measurementTypeLabel: "Tipe Pengukuran",
|
||||
measurementValueLabel: "Nilai Pengukuran",
|
||||
fieldName: "Nama field",
|
||||
fieldValue: "Nilai",
|
||||
deleteModel: "Hapus",
|
||||
uploaded: "Terunggah",
|
||||
noImage: "Belum ada gambar",
|
||||
changeImage: "Ganti Gambar",
|
||||
uploadModelImage: "Unggah Gambar Model",
|
||||
uploading: "Mengunggah...",
|
||||
},
|
||||
overview: {
|
||||
title: "Ikhtisar Eksekutif",
|
||||
subtitle: "Metrik performa real-time untuk Divisi Ekspor.",
|
||||
totalProducts: "Total Produk",
|
||||
totalBuyers: "Total Pembeli",
|
||||
refunds: "Pengembalian",
|
||||
vsLastMonth: "vs bulan lalu",
|
||||
globalReach: "Jangkauan global aktif",
|
||||
returnRate: "tingkat pengembalian",
|
||||
ordersAnalytics: "Analitik Pesanan",
|
||||
ordersSubtitle: "Frekuensi pesanan harian dan siklus pemenuhan",
|
||||
last30Days: "30 Hari Terakhir",
|
||||
lastQuarter: "Kuartal Terakhir",
|
||||
earnings: "Penghasilan",
|
||||
grossRevenue: "Pendapatan Kotor",
|
||||
directSales: "Penjualan Langsung",
|
||||
retailPartners: "Mitra Ritel",
|
||||
affiliates: "Afiliasi",
|
||||
recentOrders: "Pesanan Terbaru",
|
||||
viewAll: "Lihat Semua Operasi",
|
||||
productDetails: "Detail Produk",
|
||||
customer: "Pelanggan",
|
||||
transactionDate: "Tanggal Transaksi",
|
||||
amount: "Jumlah",
|
||||
status: "Status",
|
||||
action: "Aksi",
|
||||
wk: "MG",
|
||||
},
|
||||
},
|
||||
};
|
||||
135
src/lib/use-product-submit.ts
Normal file
135
src/lib/use-product-submit.ts
Normal file
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useProductDraft, type ProductDraftState } from "./product-draft";
|
||||
|
||||
function getToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||
}
|
||||
|
||||
function toNumber(value: string) {
|
||||
const normalized = value.replace(/\./g, "").replace(/,/g, ".");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | "PUBLISHED") {
|
||||
return {
|
||||
subCategory: draft.subCategoryId ? { id: draft.subCategoryId } : undefined,
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
isPreOrder: draft.isPreOrder,
|
||||
preOrderDay: draft.isPreOrder ? toNumber(draft.preOrderDay) : 0,
|
||||
isNew: draft.isNew,
|
||||
isEligibleToExport: draft.isEligibleToExport,
|
||||
imageId: draft.imageId || undefined,
|
||||
productFiles: (draft.productFiles ?? []).map((f) => f.id),
|
||||
productImages: draft.productImages
|
||||
.filter(Boolean)
|
||||
.map((imageId, index) => ({ imageId, sequence: index + 1 })),
|
||||
productKeyWords: draft.keywords.filter(Boolean),
|
||||
productFeatures: draft.features.filter(Boolean),
|
||||
productModels: draft.models.map((model) => ({
|
||||
name: model.name,
|
||||
sku: model.sku,
|
||||
imageId: model.imageId || undefined,
|
||||
price: toNumber(model.price),
|
||||
currency: model.currency,
|
||||
weight: toNumber(model.weight),
|
||||
weightType: model.weightType || "G",
|
||||
length: toNumber(model.length),
|
||||
width: toNumber(model.width),
|
||||
height: toNumber(model.height),
|
||||
dimensionType: model.dimensionType || "CM",
|
||||
isMeasurement: model.measurements.length > 0,
|
||||
isConfigurePromotionPrice: model.hasPromotion,
|
||||
promotionPrice: model.hasPromotion ? toNumber(model.promotionPrice) : 0,
|
||||
promotionCurrency: model.promotionCurrency || model.currency,
|
||||
promotionStartDate: model.promotionStartDate || undefined,
|
||||
promotionEndDate: model.promotionEndDate || undefined,
|
||||
packagingWeight: toNumber(model.packagingWeight),
|
||||
packagingWeightType: model.packagingWeightType || "G",
|
||||
packagingLength: toNumber(model.packagingLength),
|
||||
packagingWidth: toNumber(model.packagingWidth),
|
||||
packagingHeight: toNumber(model.packagingHeight),
|
||||
packagingDimensionType: model.packagingDimensionType || "CM",
|
||||
warehouses: model.warehouses
|
||||
.filter((w) => w.id)
|
||||
.map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
|
||||
productMeasurements: model.measurements.map((m) => ({
|
||||
measurementType: m.measurementType,
|
||||
measurementValue: m.measurementValue,
|
||||
price: toNumber(m.price),
|
||||
currency: m.currency,
|
||||
weight: toNumber(m.weight),
|
||||
weightType: m.weightType || "G",
|
||||
length: toNumber(m.length),
|
||||
width: toNumber(m.width),
|
||||
height: toNumber(m.height),
|
||||
dimensionType: m.dimensionType || "CM",
|
||||
isConfigurePromotionPrice: m.hasPromotion,
|
||||
promotionPrice: m.hasPromotion ? toNumber(m.promotionPrice) : 0,
|
||||
promotionCurrency: m.promotionCurrency || m.currency,
|
||||
promotionStartDate: m.promotionStartDate || undefined,
|
||||
promotionEndDate: m.promotionEndDate || undefined,
|
||||
packagingWeight: toNumber(m.packagingWeight),
|
||||
packagingWeightType: m.packagingWeightType || "G",
|
||||
packagingLength: toNumber(m.packagingLength),
|
||||
packagingWidth: toNumber(m.packagingWidth),
|
||||
packagingHeight: toNumber(m.packagingHeight),
|
||||
packagingDimensionType: m.packagingDimensionType || "CM",
|
||||
warehouses: m.warehouses
|
||||
.filter((w) => w.id)
|
||||
.map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
|
||||
})),
|
||||
})),
|
||||
productInformations: draft.productInformations.filter(
|
||||
(i) => i.paramName && i.paramValue
|
||||
),
|
||||
categoryInformations: draft.categoryInformations.filter(
|
||||
(i) => i.paramName && i.paramValue
|
||||
),
|
||||
complianceInformation: { ...draft.complianceInformation },
|
||||
warrantyInformation: {
|
||||
...draft.warrantyInformation,
|
||||
duration: toNumber(draft.warrantyInformation.duration),
|
||||
},
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProductSubmit() {
|
||||
const { draft, resetDraft } = useProductDraft();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [errorLog, setErrorLog] = useState<{ request: unknown; response: unknown } | null>(null);
|
||||
|
||||
async function submit(state: "DRAFT" | "PUBLISHED"): Promise<void> {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
setErrorLog(null);
|
||||
try {
|
||||
const token = getToken();
|
||||
const payload = buildProductPayload(draft, state);
|
||||
const res = await fetch("/api/products/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-auth-token": token },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) {
|
||||
setErrorLog({ request: payload, response: result });
|
||||
throw new Error(result?.responseDesc || "Gagal menyimpan produk");
|
||||
}
|
||||
resetDraft();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Gagal menyimpan produk");
|
||||
throw err;
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { submit, submitting, error, errorLog, setError };
|
||||
}
|
||||
Reference in New Issue
Block a user