diff --git a/next.config.ts b/next.config.ts index e9ffa30..2cdcdcd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index 42aa5af..bb9ed18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 19128ed..3f13821 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..0de12f3 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,7 @@ + + + + INA + + TRADING + diff --git a/public/ina_logo.png b/public/ina_logo.png new file mode 100755 index 0000000..688de2f Binary files /dev/null and b/public/ina_logo.png differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..768c53c --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,15 @@ + + Ina Trading + INA TRADING logo with subtitle Digital Security Technology by PERURI. + + + INA + TRADING + + Digital Security Technology by + + + + + + diff --git a/src/app/(auth)/account-not-found/page.tsx b/src/app/(auth)/account-not-found/page.tsx new file mode 100644 index 0000000..28b1ccd --- /dev/null +++ b/src/app/(auth)/account-not-found/page.tsx @@ -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 ( +
+ {/* Left Side */} +
+
+
+
+ + + {a.editorialIntelligence} + +
+

+ {a.tradePrecision} +

+

+ {a.tradeSubtitle} +

+
+
+ + {/* Right Side */} +
+
+ Ina Trading +
+ + + help_outline + {a.helpLink} + +
+
+ +
+
+ person_off +
+ +
+

+ {a.title} +

+
+

{email}

+ + {a.change} + +
+
+ +
+ + {a.createAccount} + + +
+ + + + + login + + {a.loginOther} + +
+
+ + +
+
+ ); +} + +export default function AccountNotFoundPage() { + return ( + + + + ); +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..c78ce68 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -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) { + e.preventDefault(); + if (!contact.trim()) return; + setSubmitted(true); + } + + return ( + <> +
+
+
+
+
+
+ +
+
+
+ + {f.securityFirst} + +
+

+ {f.heroTitle} +

+

+ {f.heroSubtitle} +

+
+ +
+
+ 99.9% + {f.uptime} +
+
+ 24/7 + {f.fraudMonitoring} +
+
+
+ +
+
+ Ina Trading +
+ +
+ +
+ +
+
+

+ {f.title} +

+

{f.subtitle}

+
+ +
+ {submitted && ( +
+ {f.apiNotReady} {contact}. +
+ )} + +
+ +
+ + alternate_email + + 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" + /> +
+

+ {/* privacy note kept short */} +

+
+ +
+ +
+
+ +
+ + + chevron_left + + {f.backToLogin} + + +
+ contact_support +

+ {f.havingTrouble}{" "} + {f.supportLink} +

+
+
+
+ +
+
+
+ +
+ +
+ + ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..d111064 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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 ( +
+ {/* Left Side */} +
+
+
+
+
+
+

+ {l.heroTitle} +

+

+ {l.heroSubtitle} +

+
+
+ + {/* Right Side */} +
+
+ {/* Mobile Logo + Language Toggle */} +
+ Ina Trading + +
+ + {/* Desktop language toggle */} +
+ Ina Trading + +
+ +
+

+ {l.title} +

+

{l.subtitle}

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+ + person + + 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" + /> +
+
+ +
+
+ + + {l.forgotPassword} + +
+
+ + lock + + 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" + /> + +
+
+ +
+ setRemember(e.target.checked)} + className="w-5 h-5 rounded border-outline-variant text-primary focus:ring-primary" + /> + +
+ +
+ +
+
+ +
+

+ {l.noAccount}{" "} + + {l.registerFree} + +

+
+
+
+ +
+ ); +} diff --git a/src/app/(auth)/register/complete/page.tsx b/src/app/(auth)/register/complete/page.tsx new file mode 100644 index 0000000..2d7a994 --- /dev/null +++ b/src/app/(auth)/register/complete/page.tsx @@ -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 ( +
+
+
+ +
+
+
+ {c.finalStep} +
+

+ {c.title} +

+

{c.subtitle}

+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + + + {c.backToRegister} + +
+
+
+ ); +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..c3fa10a --- /dev/null +++ b/src/app/(auth)/register/page.tsx @@ -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("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 ( +
+ {/* Left Side */} +
+
+
+
+
+
+

+ {r.joinNetwork} +

+

+ {r.joinSubtitle} +

+
+
+
98%
+
{r.successRate}
+
+
+
24/7
+
{r.marketPulse}
+
+
+
+
+ + {/* Right Side */} +
+
+
+
+
+ Ina Trading + +
+

+ {r.title} +

+

{r.subtitle}

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Role Selector */} +
+ +
+ + +
+
+ + {/* Email */} +
+ + 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 && ( +

{r.emailFromPrevious}

+ )} +
+ + {/* Name */} +
+ + setName(e.target.value)} + placeholder="John Doe" + required + className={authInputClass} + /> +
+ + {/* Mobile */} +
+ + setMobile(e.target.value)} + placeholder="+62 812 3456 789" + required + className={authInputClass} + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder={r.passwordPlaceholder} + required + className={authInputWithIconClass} + /> + +
+
+ + {/* Confirm Password */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder={r.confirmPasswordPlaceholder} + required + className={authInputWithIconClass} + /> + +
+
+ +
+ + +
+

+ {r.haveAccount}{" "} + + {r.signIn} + +

+
+
+
+ + {/* Step Progress */} +
+
+
+
+
+
+ + {r.stepOf} + +
+
+ +
+

+ {r.termsAgreement}{" "} + {t.common.terms} {r.and}{" "} + {t.common.privacy} {r.inaTrading} +

+
+
+
+ ); +} + +export default function RegisterPage() { + return ( + + + + ); +} diff --git a/src/app/(auth)/register/verify/page.tsx b/src/app/(auth)/register/verify/page.tsx new file mode 100644 index 0000000..950fe53 --- /dev/null +++ b/src/app/(auth)/register/verify/page.tsx @@ -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) { + 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 ( +
+ {/* Left Side */} +
+
+
+
+

+ {v.secureYourFuture} +

+
+

+ {v.verifyIdentity} +

+
+
+

99.9%

+

{v.transactionSecurity}

+
+
+

256-bit

+

{v.bankLevelEncryption}

+
+
+
+
+ + {/* Right Side */} +
+ {/* Logo */} +
+ Ina Trading +
+ +
+
+
+ + mark_email_read + +
+

+ {v.title} +

+

+ {v.subtitle}{" "} + {email} + {v.subtitleSuffix} +

+
+ +
+ {error && ( +
+
{error}
+ {errorStep ? ( +
+ Step: {errorStep} +
+ ) : null} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* OTP Grid */} +
+ {otp.map((digit, index) => ( + { 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" + /> + ))} +
+ +
+ {v.noCode} + +
+ +
+ +
+
+ +
+
+
+ shield + + {v.securityTitle} + +
+

{v.securityDesc}

+
+
+
+ + + close + {t.common.cancel} + + +
+ +
+
+
+ ); +} + +export default function VerifyPage() { + return ( + + + + ); +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..113a568 --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -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) { + 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 ( + <> +
+
+
+ Ina Trading +
+ +
+

+ Secure.
+ Refined.
+ Absolute. +

+

+ Protecting your financial ecosystem with world-class encryption + and institutional-grade security protocols. +

+
+ +
+
+
+ Security Status +
+
+ 99.9% Up +
+
+
+
+ Data Protection +
+
+ AES-256 +
+
+
+ +
+
+
+
+
+
+ +
+
+
+ Ina Trading + +
+
+ +
+ +
+
+

+ Reset password +

+

+ Enter your new credentials to regain access to your trading + dashboard. +

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {submitted && ( +
+ Reset password API belum tersedia. Screen ini sudah siap dan + submit reset password berhasil disimulasikan. +
+ )} + +
+ +
+ 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" + /> + +
+
+ +
+ +
+ 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" + /> + +
+
+ +
+

+ Requirement:{" "} + Minimum 12 characters, including one uppercase letter, one + special character, and one numeric value. +

+
+ + 12+ characters + + + Uppercase letter + + + Numeric value + + + Special character + +
+
+ +
+ +
+
+ +
+

+ Remember your password? + + Go back to Login + +

+
+
+ +
+ +
+
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/help/page.tsx b/src/app/(dashboard)/dashboard/help/page.tsx new file mode 100644 index 0000000..1bb0d85 --- /dev/null +++ b/src/app/(dashboard)/dashboard/help/page.tsx @@ -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 ( +
+ {/* Hero */} +
+ {/* Dot grid */} +
+ {/* Decorative accent */} +
+
+
+ +
+

+ How can we help
+ you today? +

+
+ + search + + 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" + /> +
+
+
+ + {/* Popular Categories */} +
+
+
+

Resource Infrastructure

+

Popular Categories

+
+
+
+ +
+ {categories.map((cat) => ( +
+
+ {cat.icon} +

{cat.title}

+

{cat.desc}

+
+ ))} +
+
+ + {/* FAQ + Contact Support */} +
+ {/* FAQ */} +
+

+ Top Frequently Asked Questions +

+
+ {faqs.map((q) => ( +
+ {q} + + arrow_forward + +
+ ))} +
+
+ + {/* Contact Support */} +
+
+
+

+ Can't find what you're looking for? +

+

+ Our expert trade curators are available 24/7 for institutional grade support and technical troubleshooting. +

+
+
+ + +
+
+
+ + {/* Footer */} +
+
+
Ina Trading
+

+ © 2024 Ina Trading Marketplace. All Rights Reserved. +

+
+
+ {["Privacy Policy", "Compliance Framework", "Terms of Service", "Operational Status"].map((l) => ( + + {l} + + ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..6714245 --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+ {/* Hero Title */} +
+

+ {d.title} +

+

{d.subtitle}

+
+ + {/* Stats Grid */} +
+ {/* Total Products */} +
+
+

+ {d.totalProducts} +

+

1,284

+
+ trending_up + +12.5% {d.vsLastMonth} +
+
+ + {/* Total Buyers */} +
+
+

+ {d.totalBuyers} +

+

42,502

+
+ group + {d.globalReach} +
+
+ + {/* Refunds */} +
+
+

+ {d.refunds} +

+

142

+
+ history + 0.3% {d.returnRate} +
+
+
+ + {/* Analytics Row */} +
+ {/* Orders Analytics Chart */} +
+
+
+

{d.ordersAnalytics}

+

{d.ordersSubtitle}

+
+ +
+ + {/* Bar Chart */} +
+ {barHeights.map((height, i) => ( +
+ ))} +
+
+ {d.wk} 1 + {d.wk} 2 + {d.wk} 3 + {d.wk} 4 +
+
+ + {/* Earnings */} +
+

{d.earnings}

+ + {/* Donut Chart Placeholder */} +
+
+
+
+ $84.2k + + {d.grossRevenue} + +
+
+
+ + {/* Legend */} +
+ {[ + { 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) => ( +
+
+ + {item.label} +
+ {item.pct} +
+ ))} +
+
+
+ + {/* Recent Orders Table */} +
+
+

{d.recentOrders}

+ +
+
+ + + + + + + + + + + + + {recentOrders.map((order) => ( + + + + + + + + + ))} + +
{d.productDetails}{d.customer}{d.transactionDate}{d.amount}{d.status}{d.action}
+
+
+ inventory_2 +
+
+

{order.product}

+

SKU: {order.sku}

+
+
+
+

{order.customer}

+

{order.location}

+
{order.date}{order.amount} + + {order.status} + + + +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx b/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx new file mode 100644 index 0000000..0ae4260 --- /dev/null +++ b/src/app/(dashboard)/dashboard/warehouse/WarehouseForm.tsx @@ -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(initialData ?? defaultWarehouseForm); + const [provinces, setProvinces] = useState([]); + const [cities, setCities] = useState([]); + 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) { + 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 = { + 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 ( +
+ {/* Page Header */} +
+
+ Management Dashboard +

{pageTitle}

+

{pageSubtitle}

+
+ +
+ + {/* Form Card */} +
+

Facility Details

+ +
+ {/* Name */} +
+ + update({ name: e.target.value })} + placeholder="Contoh: Gudang Utama Jakarta..." + className={inputCls} + /> +
+ + {/* Address */} +
+ + update({ address: e.target.value })} + placeholder="Jl. Nama Jalan No. ..." + className={inputCls} + /> +
+ + {/* Country */} +
+ + +
+ + {/* Province */} +
+ + {isIndonesia ? ( + + ) : ( + update({ province: e.target.value })} + placeholder="Nama provinsi / state..." + className={inputCls} + /> + )} +
+ + {/* City */} +
+ + {isIndonesia ? ( + + ) : ( + update({ city: e.target.value })} + placeholder="Nama kota..." + className={inputCls} + /> + )} +
+ + {/* Postal Code */} +
+ + update({ postalCode: e.target.value })} + placeholder="Contoh: 13920" + className={inputCls} + /> +
+ + {/* Warehouse Type */} +
+ + +
+ + {/* Lat / Lng */} +
+ + update({ latitude: e.target.value })} + placeholder="Contoh: -6.1891" + className={inputCls} + /> +
+
+ + update({ longitude: e.target.value })} + placeholder="Contoh: 106.9247" + className={inputCls} + /> +
+
+ + {/* Status messages + buttons */} +
+ {success && ( +
+ check_circle + {successMessage} +
+ )} + {error && ( +
+ error + {error} +
+ )} +
+ {savingPhase && ( +

+ progress_activity + {savingPhase} +

+ )} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/warehouse/[warehouseId]/edit/page.tsx b/src/app/(dashboard)/dashboard/warehouse/[warehouseId]/edit/page.tsx new file mode 100644 index 0000000..7ecb343 --- /dev/null +++ b/src/app/(dashboard)/dashboard/warehouse/[warehouseId]/edit/page.tsx @@ -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; + 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(() => + getCachedWarehouse(params.warehouseId) + ); + + const loadError = initialData + ? "" + : "Data tidak tersedia. Kembali ke daftar warehouse dan klik Edit lagi."; + + if (loadError) { + return ( +
+ error +

{loadError}

+ +
+ ); + } + + if (!initialData) { + return ( +
+ progress_activity +
+ ); + } + + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/warehouse/new/page.tsx b/src/app/(dashboard)/dashboard/warehouse/new/page.tsx new file mode 100644 index 0000000..bd282f8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/warehouse/new/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { WarehouseForm } from "../WarehouseForm"; + +export default function NewWarehousePage() { + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/warehouse/page.tsx b/src/app/(dashboard)/dashboard/warehouse/page.tsx new file mode 100644 index 0000000..7b847fc --- /dev/null +++ b/src/app/(dashboard)/dashboard/warehouse/page.tsx @@ -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([]); + const [filtered, setFiltered] = useState([]); + 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(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 ( +
+ {/* Delete Confirmation Modal */} + {deleteId && ( +
+
+
+
+ delete +
+

Hapus Warehouse?

+
+

+ Tindakan ini tidak bisa dibatalkan. Warehouse akan dihapus secara permanen. +

+
+ + +
+
+
+ )} + + {/* Header */} +
+
+ Management Dashboard +

Warehouse

+

Kelola gudang dan lokasi penyimpanan produk

+
+
+
+ search + setSearch(e.target.value)} + /> +
+ + add + Tambah Warehouse + +
+
+ + {/* Table */} +
+ {loading ? ( +
+ progress_activity +

Memuat data...

+
+ ) : error ? ( +
+ error +

{error}

+
+ ) : filtered.length === 0 ? ( +
+ warehouse +

Belum ada warehouse

+
+ ) : ( +
+ + + + + + + + + + + + {filtered.map((item) => ( + + {/* Warehouse name */} + + + {/* Address */} + + + {/* Postal code */} + + + {/* Type */} + + + {/* Actions */} + + + ))} + +
WarehouseAlamatKode PosTipeAksi
+
+
+ warehouse +
+
+

+ {item.name || Tanpa nama} +

+

+ {item.city && item.province ? `${item.city}, ${item.province}` : item.city || item.province || "—"} +

+
+
+
+

{item.address || "—"}

+

{item.country || ""}

+
+ {item.postalCode || "—"} + + + {item.warehouseType || "—"} + + +
+ + +
+
+
+ )} + + {/* Pagination */} + {!loading && totalItem > 0 && ( +
+ + Menampilkan {startEntry}–{endEntry} dari {totalItem} warehouse + +
+ + {Array.from({ length: Math.min(totalPage, 5) }, (_, i) => i).map((i) => ( + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..04b3eff --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -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 ( +
+ {/* Top Nav */} +
+
+ Ina Trading +
+ + +
+
+ +
+ + + +
+
+ + {/* Sidebar */} + + + {/* Main Content */} +
+ {children} +
+
+ ); +} diff --git a/src/app/(dashboard)/products/[productId]/detail/page.tsx b/src/app/(dashboard)/products/[productId]/detail/page.tsx new file mode 100644 index 0000000..590c014 --- /dev/null +++ b/src/app/(dashboard)/products/[productId]/detail/page.tsx @@ -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 ( +
+
+ {step} +
+

{title}

+
+ ); +} + +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 ( +
+ {label} + {display} +
+ ); +} + +function isNonEmptyString(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0; +} + +function ToggleBadge({ label, value }: { label: string; value: boolean }) { + return ( +
+ {label} + + {value ? "Ya" : "Tidak"} + +
+ ); +} + +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(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 ( +
+

{d.loading}

+
+ ); + } + + if (error || !product) { + return ( +
+
+ {error || d.notFound} +
+
+ ); + } + + 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 ( +
+ {/* Page Header */} +
+ +
+
+

{product.name || d.title}

+

{product.state || "DRAFT"}

+
+ + edit + {d.editProduct} + +
+
+ + {/* ── Section 01: Basic Details (Category) ───────────────────────────── */} +
+ +
+
+

{d.mainCategory}

+

{product.subCategory?.category?.name || "—"}

+
+
+

{d.subCategory}

+

{product.subCategory?.name || product.subCategory?.id || "—"}

+
+
+
+ + {/* ── Section 02: Description ────────────────────────────────────────── */} +
+ {/* Left */} +
+ + + {/* Name */} +
+

{d.officialName}

+

{product.name || "—"}

+
+ + {/* Toggles */} +
+ + +
+ + {product.isPreOrder && ( +
+

{d.preOrderDay}

+

{product.preOrderDay || "—"}

+
+ )} + + {/* Keywords */} + {keywords.length > 0 && ( +
+

{d.keywords}

+
+ {keywords.map((k: string) => ( + {k} + ))} +
+
+ )} + + {/* Description */} +
+

{d.narrative}

+

{product.description || "—"}

+
+ + {/* Features */} + {features.length > 0 && ( +
+

{d.features}

+
+ {features.map((f: string, i: number) => ( +
+ drag_indicator +

{f}

+
+ ))} +
+
+ )} +
+ + {/* Right: Visual Identity */} +
+

{d.visualIdentity}

+ {allImages.length === 0 ? ( +
+ image +
+ ) : ( +
+ {allImages.map((imgId, i) => ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {i { (e.target as HTMLImageElement).style.display = "none"; }} + /> +
+
+

+ {i === 0 ? d.mainImage : `${d.gallery} ${i}`} +

+

{t.common.uploaded}

+
+
+ ))} +
+ )} +
+ info +

+ {allImages.length} {d.imagesAvailable} +

+
+
+
+ + {/* ── Section 03: Pricing & Model ───────────────────────────────────── */} + {models.length > 0 && ( +
+
+
03
+

{d.section03} ({models.length})

+
+ {/* 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 ( +
+
+
{i + 1}
+

{m.name || `Model ${i + 1}`}

+ {m.sku && SKU: {m.sku}} + {measurements.length > 0 && ( + {measurements.length} measurement(s) + )} +
+
+ + + + {m.isConfigurePromotionPrice && } + {m.isConfigurePromotionPrice && m.promotionStartDate && ( + + )} + + +
+ {/* Warehouses */} + {Array.isArray(m.warehouses) && m.warehouses.length > 0 && ( +
+

{d.warehouseStock}

+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => ( +
+ {w.id?.slice(0, 8)}... + {w.stock ?? 0} unit +
+ ))} +
+
+ )} + {/* Measurements */} + {measurements.length > 0 && ( +
+

Measurements / Variants

+
+ {measurements.map((ms: ProductMeasurement, mi: number) => { + const msWeightUnit = ms.weightType || "G"; + const msDimUnit = ms.dimensionType || "CM"; + return ( +
+
+ + {String(mi + 1).padStart(2, "0")} + + {ms.measurementType && {ms.measurementType}} + {ms.measurementValue && — {ms.measurementValue}} +
+
+ + + + {ms.isConfigurePromotionPrice && } +
+ {Array.isArray(ms.warehouses) && + ms.warehouses.filter((w: ProductWarehouse) => w.id).length > 0 && ( +
+

Stock

+ {ms.warehouses + .filter((w: ProductWarehouse) => w.id) + .map((w: ProductWarehouse, wi: number) => ( +
+ {w.id?.slice(0, 8)}... + {w.stock ?? 0} unit +
+ ))} +
+ )} +
+ ); + })} +
+
+ )} +
+ ); + })} +
+ )} + + {/* ── Section 04: General Info ──────────────────────────────────────── */} +
+ + + {productInfos.length > 0 && ( +
+

{d.productInfo}

+ {productInfos.map((item: { paramName: string; paramValue: string }, i: number) => ( + + ))} +
+ )} + + {categoryInfos.length > 0 && ( + <> + {productInfos.length > 0 &&
} +
+

{d.categoryInfo}

+ {categoryInfos.map((item: { paramName: string; paramValue: string }, i: number) => ( + + ))} +
+ + )} + + {product.complianceInformation && ( + <> +
+
+

{d.compliance}

+ + + +
+ + )} + + {product.warrantyInformation && ( + <> +
+
+

{d.warranty}

+ + +
+ + )} + +
+
+

{d.export}

+ +
+
+ + {/* ── Fixed Bottom Footer ───────────────────────────────────────────── */} +
+
+
+ {d.modeReadOnly} +
+
+ + arrow_back + Kembali + + + edit + Edit Produk + +
+
+
+
+ ); +} + +export default function ProductDetailPage() { + return ( + + + + ); +} diff --git a/src/app/(dashboard)/products/[productId]/edit/page.tsx b/src/app/(dashboard)/products/[productId]/edit/page.tsx new file mode 100644 index 0000000..128446b --- /dev/null +++ b/src/app/(dashboard)/products/[productId]/edit/page.tsx @@ -0,0 +1,1744 @@ +"use client"; + +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useRef, useState } from "react"; +import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options"; +import { useLanguage } from "@/lib/i18n-context"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface CategoryOption { + id: string; + name: string; +} + +interface WarehouseOption { + id: string; + name: string; + address: string; + city: string; +} + +interface EditWarehouse { + id: string; + stock: number; +} + +interface EditMeasurement { + _key: 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: EditWarehouse[]; +} + +interface EditModel { + _key: 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; + warehouses: EditWarehouse[]; + measurements: EditMeasurement[]; +} + +interface EditState { + categoryId: string; + categoryName: string; + subCategoryId: string; + subCategoryName: string; + name: string; + description: string; + isPreOrder: boolean; + preOrderDay: string; + isNew: boolean; + isEligibleToExport: boolean; + imageId: string; + productImages: string[]; + keywords: string[]; + features: string[]; + models: EditModel[]; + productInformations: { paramName: string; paramValue: string }[]; + categoryInformations: { paramName: string; paramValue: string }[]; + complianceInformation: { + safetyWarning: string; + countryOfOrigin: string; + isDangerousGoodRegulation: boolean; + fileId: string; + }; + warrantyInformation: { + type: string; + duration: string; + durationType: string; + }; +} + +interface ApiWarehouse { + id?: string | number | null; + stock?: string | number | null; +} + +interface ApiMeasurement { + measurementType?: string | number | null; + measurementValue?: string | number | null; + price?: string | number | null; + currency?: string | number | null; + weight?: string | number | null; + weightType?: string | number | null; + length?: string | number | null; + width?: string | number | null; + height?: string | number | null; + dimensionType?: string | number | null; + packagingWeight?: string | number | null; + packagingWeightType?: string | number | null; + packagingLength?: string | number | null; + packagingWidth?: string | number | null; + packagingHeight?: string | number | null; + packagingDimensionType?: string | number | null; + isConfigurePromotionPrice?: boolean | null; + promotionPrice?: string | number | null; + promotionCurrency?: string | number | null; + promotionStartDate?: string | number | null; + promotionEndDate?: string | number | null; + warehouses?: ApiWarehouse[]; +} + +interface ApiModel extends ApiMeasurement { + name?: string | number | null; + sku?: string | number | null; + imageId?: string | number | null; + image?: string | number | null; + warehouses?: ApiWarehouse[]; + productMeasurements?: ApiMeasurement[]; +} + +interface ApiProductImage { + sequence?: number | null; + imageId?: string | number | null; +} + +interface ApiParamItem { + paramName?: string; + paramValue?: string; +} + +function toParamItems(items: ApiParamItem[] | undefined) { + if (!Array.isArray(items)) return []; + return items.map((item: ApiParamItem) => ({ + paramName: toStr(item.paramName), + paramValue: toStr(item.paramValue), + })); +} + +interface ApiProduct { + subCategory?: { + id?: string | number | null; + name?: string | number | null; + } | null; + name?: string | number | null; + description?: string | number | null; + isPreOrder?: boolean | null; + preOrderDay?: string | number | null; + isNew?: boolean | null; + isEligibleToExport?: boolean | null; + imageId?: string | number | null; + productImages?: ApiProductImage[]; + productKeyWords?: string[]; + productFeatures?: string[]; + productModels?: ApiModel[]; + productInformations?: ApiParamItem[]; + categoryInformations?: ApiParamItem[]; + complianceInformation?: { + safetyWarning?: string | number | null; + countryOfOrigin?: string | number | null; + isDangerousGoodRegulation?: boolean | null; + fileId?: string | number | null; + file?: string | number | null; + } | null; + warrantyInformation?: { + type?: string | number | null; + duration?: string | number | null; + durationType?: string | number | null; + } | null; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getToken() { + if (typeof window === "undefined") return ""; + return sessionStorage.getItem("token") || localStorage.getItem("token") || ""; +} + +function toNum(v: string | number | null | undefined): number { + const n = Number(String(v ?? "").replace(/,/g, "")); + return Number.isFinite(n) ? n : 0; +} + +function toStr(v: string | number | null | undefined): string { + if (v === null || v === undefined) return ""; + return String(v); +} + +function newMeasurement(): EditMeasurement { + return { + _key: `meas-${Date.now()}-${Math.random().toString(36).slice(2)}`, + measurementType: "", + measurementValue: "", + 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: "", + warehouses: [{ id: "", stock: 0 }], + }; +} + +function newModel(index: number): EditModel { + return { + _key: `model-${Date.now()}-${index}`, + 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: "", + warehouses: [{ id: "", stock: 0 }], + measurements: [], + }; +} + +function apiToEditState(data: ApiProduct): EditState { + const rawModels = Array.isArray(data?.productModels) ? data.productModels : []; + + const models: EditModel[] = rawModels.length > 0 + ? rawModels.map((m: ApiModel, i: number) => ({ + _key: `loaded-${i}`, + name: toStr(m.name), + sku: toStr(m.sku), + imageId: toStr(m.imageId ?? m.image), + price: toStr(m.price), + currency: toStr(m.currency) || "IDR", + weight: toStr(m.weight), + weightType: toStr(m.weightType) || "G", + length: toStr(m.length), + width: toStr(m.width), + height: toStr(m.height), + dimensionType: toStr(m.dimensionType) || "CM", + packagingWeight: toStr(m.packagingWeight), + packagingWeightType: toStr(m.packagingWeightType) || "G", + packagingLength: toStr(m.packagingLength), + packagingWidth: toStr(m.packagingWidth), + packagingHeight: toStr(m.packagingHeight), + packagingDimensionType: toStr(m.packagingDimensionType) || "CM", + hasPromotion: !!m.isConfigurePromotionPrice, + promotionPrice: toStr(m.promotionPrice), + promotionCurrency: toStr(m.promotionCurrency) || toStr(m.currency) || "IDR", + promotionStartDate: toStr(m.promotionStartDate), + promotionEndDate: toStr(m.promotionEndDate), + warehouses: Array.isArray(m.warehouses) && m.warehouses.length > 0 + ? m.warehouses.map((w: ApiWarehouse) => ({ + id: toStr(w.id), + stock: Number(w.stock ?? 0), + })) + : [{ id: "", stock: 0 }], + measurements: Array.isArray(m.productMeasurements) + ? m.productMeasurements.map((ms: ApiMeasurement, mi: number) => ({ + _key: `loaded-meas-${i}-${mi}`, + measurementType: toStr(ms.measurementType), + measurementValue: toStr(ms.measurementValue), + price: toStr(ms.price), + currency: toStr(ms.currency) || "IDR", + weight: toStr(ms.weight), + weightType: toStr(ms.weightType) || "G", + length: toStr(ms.length), + width: toStr(ms.width), + height: toStr(ms.height), + dimensionType: toStr(ms.dimensionType) || "CM", + packagingWeight: toStr(ms.packagingWeight), + packagingWeightType: toStr(ms.packagingWeightType) || "G", + packagingLength: toStr(ms.packagingLength), + packagingWidth: toStr(ms.packagingWidth), + packagingHeight: toStr(ms.packagingHeight), + packagingDimensionType: toStr(ms.packagingDimensionType) || "CM", + hasPromotion: !!ms.isConfigurePromotionPrice, + promotionPrice: toStr(ms.promotionPrice), + promotionCurrency: toStr(ms.promotionCurrency) || toStr(ms.currency) || "IDR", + promotionStartDate: toStr(ms.promotionStartDate), + promotionEndDate: toStr(ms.promotionEndDate), + warehouses: Array.isArray(ms.warehouses) && ms.warehouses.length > 0 + ? ms.warehouses.map((w: ApiWarehouse) => ({ + id: toStr(w.id), + stock: Number(w.stock ?? 0), + })) + : [{ id: "", stock: 0 }], + })) + : [], + })) + : [newModel(0)]; + + return { + categoryId: "", + categoryName: "", + subCategoryId: toStr(data?.subCategory?.id), + subCategoryName: toStr(data?.subCategory?.name), + name: toStr(data?.name), + description: toStr(data?.description), + isPreOrder: !!data?.isPreOrder, + preOrderDay: toStr(data?.preOrderDay), + isNew: data?.isNew !== false, + isEligibleToExport: !!data?.isEligibleToExport, + imageId: toStr(data?.imageId), + productImages: Array.isArray(data?.productImages) + ? data.productImages + .sort( + (a: ApiProductImage, b: ApiProductImage) => + (a.sequence ?? 0) - (b.sequence ?? 0) + ) + .map((img: ApiProductImage) => toStr(img.imageId)) + : [], + keywords: Array.isArray(data?.productKeyWords) ? data.productKeyWords.filter(Boolean) : [], + features: Array.isArray(data?.productFeatures) ? data.productFeatures.filter(Boolean) : [], + models, + productInformations: toParamItems(data?.productInformations), + categoryInformations: toParamItems(data?.categoryInformations), + complianceInformation: { + safetyWarning: toStr(data?.complianceInformation?.safetyWarning), + countryOfOrigin: toStr(data?.complianceInformation?.countryOfOrigin), + isDangerousGoodRegulation: !!data?.complianceInformation?.isDangerousGoodRegulation, + fileId: toStr(data?.complianceInformation?.fileId ?? data?.complianceInformation?.file), + }, + warrantyInformation: { + type: toStr(data?.warrantyInformation?.type), + duration: toStr(data?.warrantyInformation?.duration), + durationType: toStr(data?.warrantyInformation?.durationType) || "MONTH", + }, + }; +} + +const MAX_IMAGES = 8; +const inputCls = "w-full bg-surface-container-low rounded-xl border border-outline-variant/10 p-3.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 focus:outline-none"; + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function SectionHeader({ step, title }: { step: string; title: string }) { + return ( +
+
+ {step} +
+

{title}

+
+ ); +} + +function ImageSlotUpload({ + fileId, + label, + onUploaded, + onRemove, +}: { + fileId: string; + label: string; + onUploaded: (id: string) => void; + onRemove: () => void; +}) { + const { t } = useLanguage(); + const e = t.dashboard.productEdit; + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + + async function handleChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + 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); + } catch (err) { + setError(err instanceof Error ? err.message : "Upload gagal"); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ""; + } + } + + return ( +
+
+ + {fileId ? "image" : "add_photo_alternate"} + +
+
+

{label}

+

{fileId ? e.uploaded : e.noImage}

+ {error &&

{error}

} +
+
+ + +
+ +
+ ); +} + +function ModelImageUpload({ value, onUploaded }: { value: string; onUploaded: (id: string) => void }) { + const { t } = useLanguage(); + const e = t.dashboard.productEdit; + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + const [previewUrl, setPreviewUrl] = useState(""); + + async function handleChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const objectUrl = URL.createObjectURL(file); + setPreviewUrl(objectUrl); + 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); + } catch (err) { + setError(err instanceof Error ? err.message : "Upload gagal"); + setPreviewUrl(""); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ""; + } + } + + const hasImage = value || previewUrl; + + return ( +
!uploading && inputRef.current?.click()} + > + {hasImage ? ( + <> +
+ {previewUrl ? ( + Model preview + ) : ( +
+ image +

{e.uploaded}

+
+ )} +
+
+ + {uploading ? e.uploading : e.changeImage} + +
+ + ) : ( +
+ {uploading ? ( +
+
+

{e.uploading}

+
+ ) : ( + <> + add_photo_alternate +

{e.uploadModelImage}

+ + )} +
+ )} + {error && ( +
{error}
+ )} + +
+ ); +} + +function MeasurementCard({ + measurement, + index, + warehouses, + onChange, + onRemove, +}: { + measurement: EditMeasurement; + index: number; + warehouses: WarehouseOption[]; + onChange: (updated: EditMeasurement) => void; + onRemove: () => void; +}) { + const { t } = useLanguage(); + const e = t.dashboard.productEdit; + + function set(field: keyof EditMeasurement, value: unknown) { + onChange({ ...measurement, [field]: value }); + } + + function updateWh(wi: number, field: "id" | "stock", value: string | number) { + onChange({ ...measurement, warehouses: measurement.warehouses.map((w, i) => i === wi ? { ...w, [field]: value } : w) }); + } + + const inp = "w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 text-xs font-bold focus:ring-1 focus:ring-primary/20 focus:outline-none"; + const sel = "w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 text-[10px] font-bold focus:ring-1 focus:ring-primary/20 focus:outline-none"; + + return ( +
+
+ {e.measurementLabel} {String(index + 1).padStart(2, "0")} +
+ + +
+ {/* Core Information */} +
+

{e.coreInformation}

+
+
+ + set("measurementType", ev.target.value)} /> +
+
+ + set("measurementValue", ev.target.value)} /> +
+
+
+ + {/* Pricing & Physical Specs */} +
+

{e.pricingSpecs}

+
+
+ + set("price", ev.target.value)} /> +
+
+ + +
+
+ + +
+
+ + set("weight", ev.target.value)} /> +
+
+ +
+ + set("length", ev.target.value)} /> + set("width", ev.target.value)} /> + set("height", ev.target.value)} /> +
+
+
+
+ + {/* Promotion & Packaging */} +
+ {/* Promotion */} +
+
+ {e.promotion} + +
+
+
+ + set("promotionPrice", ev.target.value)} /> +
+
+ + +
+
+
+ + set("promotionStartDate", ev.target.value)} /> +
+
+ + set("promotionEndDate", ev.target.value)} /> +
+
+
+
+ + {/* Packaging */} +
+ {e.packaging} +
+ +
+ + set("packagingWeight", ev.target.value)} /> +
+
+
+ +
+ + set("packagingLength", ev.target.value)} /> + set("packagingWidth", ev.target.value)} /> + set("packagingHeight", ev.target.value)} /> +
+
+
+
+ + {/* Measurement Warehouses */} +
+
+ {e.stock} — {e.measurementLabel} {String(index + 1).padStart(2, "0")} + +
+
+ {measurement.warehouses.map((wh, wi) => ( +
+ + updateWh(wi, "stock", Number(ev.target.value || 0))} + placeholder={e.stock} type="number" min="0" + className="w-24 bg-white border border-neutral-200 rounded-xl p-3 text-sm font-semibold text-center focus:ring-2 focus:ring-primary/10 focus:outline-none" /> + {measurement.warehouses.length > 1 && ( + + )} +
+ ))} +
+
+
+
+ ); +} + +function ModelCard({ + model, + index, + total, + warehouses, + onChange, + onRemove, +}: { + model: EditModel; + index: number; + total: number; + warehouses: WarehouseOption[]; + onChange: (m: EditModel) => void; + onRemove: () => void; +}) { + const { t } = useLanguage(); + const e = t.dashboard.productEdit; + + function set(field: keyof EditModel, value: unknown) { + onChange({ ...model, [field]: value }); + } + + function updateWh(wi: number, field: "id" | "stock", value: string | number) { + onChange({ ...model, warehouses: model.warehouses.map((w, i) => i === wi ? { ...w, [field]: value } : w) }); + } + + const hasMeasurements = model.measurements.length > 0; + + const inp = "w-full bg-surface-container-low border border-outline-variant/10 rounded-xl px-3 py-2.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 focus:outline-none"; + const sel = "w-full bg-surface-container-low border border-outline-variant/10 rounded-xl px-3 py-2.5 text-xs font-bold focus:ring-2 focus:ring-primary/10 focus:outline-none"; + + return ( +
+ {/* Header */} +
+
+
{index + 1}
+
+ set("name", ev.target.value)} + placeholder={`Model ${String(index + 1).padStart(2, "0")} Name`} + className="bg-transparent border-none text-sm font-black tracking-wide text-on-surface focus:ring-0 p-0 placeholder-outline/40 w-48" + /> + set("sku", ev.target.value)} + placeholder="SKU" + className="bg-transparent border-none text-xs font-bold text-outline focus:ring-0 p-0 placeholder-outline/30 w-32" + /> +
+
+ {total > 1 && ( + + )} +
+ +
+ {/* Image + Pricing Grid */} +
+ {/* Model Image */} +
+ + set("imageId", id)} /> +
+ + {/* Pricing & Specs */} +
+ {/* Price row */} +
+
+ + set("price", ev.target.value)} placeholder="0" type="number" min="0" className={inp} /> +
+
+ + +
+
+ + {/* Weight row */} +
+ +
+ + set("weight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} /> +
+
+ + {/* Dimensions row */} +
+ +
+ + set("length", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} /> + set("width", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} /> + set("height", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} /> +
+
+
+
+ + {/* Promotion & Packaging */} +
+ {/* Promotion */} +
+
+ {e.promotion} + +
+
+
+
+ + set("promotionPrice", ev.target.value)} placeholder="0" type="number" min="0" className={inp} /> +
+
+ + +
+
+
+
+ + set("promotionStartDate", ev.target.value)} className={inp} /> +
+
+ + set("promotionEndDate", ev.target.value)} className={inp} /> +
+
+
+
+ + {/* Packaging */} +
+ {e.packagingFootprint} +
+ +
+ + set("packagingWeight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} /> +
+
+
+ +
+ + set("packagingLength", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} /> + set("packagingWidth", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} /> + set("packagingHeight", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} /> +
+
+
+
+ + {/* Model Warehouses */} +
+
+ + +
+
+ {model.warehouses.map((wh, wi) => ( +
+ + updateWh(wi, "stock", Number(ev.target.value || 0))} placeholder={e.stock} type="number" min="0" + className="w-24 bg-surface-container-low rounded-xl border border-outline-variant/10 p-3.5 text-sm font-semibold text-center focus:ring-2 focus:ring-primary/10 focus:outline-none" /> + {model.warehouses.length > 1 && ( + + )} +
+ ))} +
+
+ + {/* Measurements */} +
+
+
+ +

{hasMeasurements ? `${model.measurements.length} ${e.variantAdded}` : e.addSizeVariant}

+
+ +
+ {model.measurements.length > 0 && ( +
+ {model.measurements.map((ms, mi) => ( + onChange({ ...model, measurements: model.measurements.map((m, i) => i === mi ? updated : m) })} + onRemove={() => onChange({ ...model, measurements: model.measurements.filter((_, i) => i !== mi) })} + /> + ))} +
+ )} +
+
+
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function EditProductPage() { + return ( + + + + ); +} + +function EditProductPageInner() { + const params = useParams<{ productId: string }>(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isDraftParam = searchParams.get("draft") === "1"; + const { t } = useLanguage(); + const e = t.dashboard.productEdit; + + const [form, setForm] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + const [saveSuccess, setSaveSuccess] = useState(false); + const [errorLog, setErrorLog] = useState<{ request: unknown; response: unknown } | null>(null); + const [errorLogCopied, setErrorLogCopied] = useState(false); + + const [productState, setProductState] = useState(isDraftParam ? "DRAFT" : ""); + const [categories, setCategories] = useState([]); + const [subCategories, setSubCategories] = useState([]); + const [loadingSubcategories, setLoadingSubcategories] = useState(false); + const [warehouses, setWarehouses] = useState([]); + const [keywordInput, setKeywordInput] = useState(""); + const [debugPayload, setDebugPayload] = useState(""); + const [publishing, setPublishing] = useState(false); + const [publishSuccess, setPublishSuccess] = useState(false); + + // Load product, categories (with subcategory resolution), and warehouses + useEffect(() => { + if (!params.productId) return; + + async function loadAll() { + setLoading(true); + setLoadError(""); + try { + const token = getToken(); + const [productRes, catRes, whRes] = await Promise.all([ + fetch(`/api/products/${params.productId}${isDraftParam ? "?draft=1" : ""}`, { headers: { "x-auth-token": token } }), + fetch("/api/products/categories?size=100", { headers: { "x-auth-token": token } }), + fetch("/api/products/warehouses?size=100", { headers: { "x-auth-token": token } }), + ]); + + const [productJson, catJson, whJson] = await Promise.all([ + productRes.json(), + catRes.json(), + whRes.json(), + ]); + + if (!productRes.ok) throw new Error(productJson?.responseDesc || "Gagal memuat produk"); + + const rawProduct = productJson?.data || productJson; + const editState = apiToEditState(rawProduct); + const subCategoryId = editState.subCategoryId; + + const cats: CategoryOption[] = Array.isArray(catJson?.rows) ? catJson.rows : []; + + // Resolve which main category owns this subcategory + if (subCategoryId && cats.length > 0) { + const subCatResults = await Promise.all( + cats.map((cat) => + fetch(`/api/products/subcategories/${cat.id}?size=100`, { headers: { "x-auth-token": token } }) + .then((r) => r.json()) + .then((j) => ({ cat, rows: Array.isArray(j?.rows) ? j.rows as CategoryOption[] : [] })) + .catch(() => ({ cat, rows: [] as CategoryOption[] })) + ) + ); + for (const { cat, rows } of subCatResults) { + const match = rows.find((s) => s.id === subCategoryId); + if (match) { + editState.categoryId = cat.id; + editState.categoryName = cat.name; + editState.subCategoryName = match.name; + break; + } + } + } + + setProductState(isDraftParam ? "DRAFT" : toStr(rawProduct?.state)); + setCategories(cats); + setForm(editState); + setWarehouses( + Array.isArray(whJson?.rows) ? whJson.rows : + Array.isArray(whJson?.data) ? whJson.data : [] + ); + } catch (err) { + setLoadError(err instanceof Error ? err.message : "Gagal memuat data"); + } finally { + setLoading(false); + } + } + + loadAll(); + }, [params.productId]); + + // Load subcategories when category changes (for draft editing) + useEffect(() => { + if (!form?.categoryId || productState !== "DRAFT") { + setSubCategories([]); + return; + } + setLoadingSubcategories(true); + fetch(`/api/products/subcategories/${form.categoryId}?size=100`, { + headers: { "x-auth-token": getToken() }, + }) + .then((r) => r.json()) + .then((j) => setSubCategories(Array.isArray(j?.rows) ? j.rows : [])) + .catch(() => setSubCategories([])) + .finally(() => setLoadingSubcategories(false)); + }, [form?.categoryId, productState]); + + function update(patch: Partial) { + setForm((prev) => prev ? { ...prev, ...patch } : prev); + } + + function updateModel(index: number, model: EditModel) { + if (!form) return; + setForm({ ...form, models: form.models.map((m, i) => i === index ? model : m) }); + } + + function addKeyword() { + if (!form) return; + const v = keywordInput.trim(); + if (!v) return; + if (!form.keywords.includes(v)) update({ keywords: [...form.keywords, v] }); + setKeywordInput(""); + } + + function buildPayload(state?: "DRAFT" | "PUBLISHED") { + if (!form) return null; + const base = { + subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined, + name: form.name, + description: form.description, + isPreOrder: form.isPreOrder, + preOrderDay: form.isPreOrder ? toNum(form.preOrderDay) : 0, + isNew: form.isNew, + isEligibleToExport: form.isEligibleToExport, + imageId: form.imageId || undefined, + productImages: form.productImages.filter(Boolean).map((imageId, i) => ({ imageId, sequence: i + 1 })), + productKeyWords: form.keywords.filter(Boolean), + productFeatures: form.features.filter(Boolean), + productModels: form.models.map((m) => ({ + name: m.name, + sku: m.sku, + imageId: m.imageId || undefined, + price: toNum(m.price), + currency: m.currency, + weight: toNum(m.weight), + weightType: m.weightType || "G", + length: toNum(m.length), + width: toNum(m.width), + height: toNum(m.height), + dimensionType: m.dimensionType || "CM", + isMeasurement: m.measurements.length > 0, + isConfigurePromotionPrice: m.hasPromotion, + promotionPrice: m.hasPromotion ? toNum(m.promotionPrice) : 0, + promotionCurrency: m.promotionCurrency || m.currency, + promotionStartDate: m.promotionStartDate || undefined, + promotionEndDate: m.promotionEndDate || undefined, + packagingWeight: toNum(m.packagingWeight), + packagingWeightType: m.packagingWeightType || "G", + packagingLength: toNum(m.packagingLength), + packagingWidth: toNum(m.packagingWidth), + packagingHeight: toNum(m.packagingHeight), + packagingDimensionType: m.packagingDimensionType || "CM", + warehouses: m.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), + productMeasurements: m.measurements.map((ms) => ({ + measurementType: ms.measurementType, + measurementValue: ms.measurementValue, + price: toNum(ms.price), + currency: ms.currency || "IDR", + weight: toNum(ms.weight), + weightType: ms.weightType || "G", + length: toNum(ms.length), + width: toNum(ms.width), + height: toNum(ms.height), + dimensionType: ms.dimensionType || "CM", + packagingWeight: toNum(ms.packagingWeight), + packagingWeightType: ms.packagingWeightType || "G", + packagingLength: toNum(ms.packagingLength), + packagingWidth: toNum(ms.packagingWidth), + packagingHeight: toNum(ms.packagingHeight), + packagingDimensionType: ms.packagingDimensionType || "CM", + isConfigurePromotionPrice: ms.hasPromotion, + promotionPrice: ms.hasPromotion ? toNum(ms.promotionPrice) : 0, + promotionCurrency: ms.promotionCurrency || ms.currency || "IDR", + promotionStartDate: ms.promotionStartDate || undefined, + promotionEndDate: ms.promotionEndDate || undefined, + warehouses: ms.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), + })), + })), + productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue), + categoryInformations: form.categoryInformations.filter((i) => i.paramName && i.paramValue), + complianceInformation: { ...form.complianceInformation }, + warrantyInformation: { ...form.warrantyInformation, duration: toNum(form.warrantyInformation.duration) }, + }; + return state ? { ...base, state } : base; + } + + async function handleSaveDraft() { + if (!form) return; + setSaving(true); + setSaveError(""); + setSaveSuccess(false); + setErrorLog(null); + setErrorLogCopied(false); + + try { + const payload = buildPayload(); + setDebugPayload(JSON.stringify(payload, null, 2)); + + const res = await fetch(`/api/products/${params.productId}?draft=1`, { + method: "PUT", + headers: { "Content-Type": "application/json", "x-auth-token": getToken() }, + body: JSON.stringify(payload), + }); + const result = await res.json(); + if (!res.ok) { + setErrorLog({ request: payload, response: result }); + throw new Error(result?.responseDesc || "Gagal menyimpan draft"); + } + + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 3000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Gagal menyimpan draft"); + } finally { + setSaving(false); + } + } + + async function handlePublish() { + if (!form) return; + setPublishing(true); + setSaveError(""); + setPublishSuccess(false); + setErrorLog(null); + setErrorLogCopied(false); + + try { + const payload = buildPayload("PUBLISHED"); + setDebugPayload(JSON.stringify(payload, null, 2)); + + const res = await fetch("/api/products/create", { + method: "POST", + headers: { "Content-Type": "application/json", "x-auth-token": getToken() }, + body: JSON.stringify(payload), + }); + const result = await res.json(); + if (!res.ok) { + setErrorLog({ request: payload, response: result }); + throw new Error(result?.responseDesc || "Gagal mempublikasikan produk"); + } + + setPublishSuccess(true); + setTimeout(() => router.push("/products"), 1500); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Gagal mempublikasikan produk"); + } finally { + setPublishing(false); + } + } + + async function handleSave() { + if (!form) return; + setSaving(true); + setSaveError(""); + setSaveSuccess(false); + setErrorLog(null); + setErrorLogCopied(false); + + try { + const payload = buildPayload(); + const payloadJson = JSON.stringify(payload, null, 2); + console.log("[EditProduct] PUT payload:", payloadJson); + setDebugPayload(payloadJson); + + const res = await fetch(`/api/products/${params.productId}`, { + method: "PUT", + headers: { "Content-Type": "application/json", "x-auth-token": getToken() }, + 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"); + } + + const reviewRes = await fetch(`/api/products/submit-review/${params.productId}`, { + method: "POST", + headers: { "x-auth-token": getToken() }, + }); + const reviewResult = await reviewRes.json(); + if (!reviewRes.ok) { + setErrorLog({ request: { submitReview: params.productId }, response: reviewResult }); + throw new Error(reviewResult?.responseDesc || "Gagal mengirim produk ke review"); + } + + setSaveSuccess(true); + setTimeout(() => router.back(), 1500); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Gagal menyimpan produk"); + } finally { + setSaving(false); + } + } + + // ── Loading / Error states ── + if (loading) { + return ( +
+

{e.loading}

+
+ ); + } + + if (loadError || !form) { + return ( +
+
+ {loadError || e.productNotFound} +
+
+ ); + } + + // ── Render ── + return ( +
+ {/* Page header */} +
+ +
+
+

{e.title}

+

{form.name || "—"}

+
+ +
+
+ + {/* ── Section 01: Basic Details ──────────────────────────────────────── */} +
+ + + {productState === "DRAFT" ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+
+ +
+ lock + + {form.categoryName || (form.categoryId ? form.categoryId : )} + +
+
+
+ +
+ lock + + {form.subCategoryName || (form.subCategoryId ? form.subCategoryId : )} + +
+

{e.categoryLocked}

+
+
+ )} +
+ + {/* ── Section 02: Description ────────────────────────────────────────── */} +
+ {/* Left: text fields */} +
+ + + {/* Name */} +
+ + update({ name: ev.target.value })} + placeholder="e.g. Vintage Italian Leather Executive Chair" className={inputCls} /> +
+ + {/* Toggles */} +
+ + +
+ + {form.isPreOrder && ( +
+ + update({ preOrderDay: ev.target.value })} + placeholder="14" type="number" min="0" className={inputCls} /> +
+ )} + + {/* Keywords */} +
+ +
+ setKeywordInput(ev.target.value)} + onKeyDown={(ev) => { if (ev.key === "Enter") { ev.preventDefault(); addKeyword(); } }} + placeholder={e.addKeyword} className={inputCls + " flex-1"} /> + +
+
+ {form.keywords.map((k) => ( + + ))} +
+
+ + {/* Description */} +
+ +