Compare commits
No commits in common. "feature/WNPT-735" and "main" have entirely different histories.
feature/WN
...
main
55 changed files with 805 additions and 2202 deletions
202
package-lock.json
generated
202
package-lock.json
generated
|
|
@ -19,18 +19,15 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^6.2.9",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.31.0",
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
@ -294,14 +291,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -316,9 +313,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -686,6 +683,9 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -702,6 +702,9 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -718,6 +721,9 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -734,6 +740,9 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -750,6 +759,9 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -766,6 +778,9 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -782,6 +797,9 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -798,6 +816,9 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -814,6 +835,9 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -836,6 +860,9 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -858,6 +885,9 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -880,6 +910,9 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -902,6 +935,9 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -924,6 +960,9 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -946,6 +985,9 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -968,6 +1010,9 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1235,6 +1280,9 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1251,6 +1299,9 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1267,6 +1318,9 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1283,6 +1337,9 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2076,17 +2133,6 @@
|
|||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
|
@ -2236,6 +2282,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2253,6 +2302,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2270,6 +2322,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2287,6 +2342,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2418,13 +2476,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -2439,16 +2490,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
|
|
@ -2880,6 +2921,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2894,6 +2938,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2908,6 +2955,9 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2922,6 +2972,9 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2936,6 +2989,9 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2950,6 +3006,9 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2964,6 +3023,9 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2978,6 +3040,9 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2992,6 +3057,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3006,6 +3074,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3045,18 +3116,6 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
|
|
@ -3068,17 +3127,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
||||
|
|
@ -5613,12 +5661,6 @@
|
|||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
|
@ -5776,6 +5818,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5797,6 +5842,9 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5818,6 +5866,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5839,6 +5890,9 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -6545,20 +6599,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
|
|
|
|||
|
|
@ -20,18 +20,15 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^6.2.9",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.31.0",
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getLocationServer } from "@/lib/location/server";
|
||||
import { AddressMapPicker } from "@/components/location/address-map-picker";
|
||||
|
||||
export default async function AddressMapPage() {
|
||||
await requireSession("/addresses/map");
|
||||
const location = await getLocationServer();
|
||||
const initialCenter: [number, number] | null = location?.mode === "geo" ? [location.lat, location.lng] : null;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
<div className="flex items-center justify-start gap-3 px-4 pt-4 pb-3">
|
||||
<Link href="/addresses" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">انتخاب موقعیت روی نقشه</h1>
|
||||
</div>
|
||||
|
||||
{/* AddressMapPicker reads useSearchParams() (the `next` return-path handoff) —
|
||||
needs Suspense or `next build` fails, even though `next dev` tolerates it. */}
|
||||
<Suspense fallback={null}>
|
||||
<AddressMapPicker initialCenter={initialCenter} />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getLocationServer } from "@/lib/location/server";
|
||||
import { getCities, getNeighborhoods } from "@/lib/api/locations";
|
||||
import { AddressDetailsForm } from "@/components/customer/address-details-form";
|
||||
|
||||
export default async function NewAddressPage({ searchParams }: PageProps<"/addresses/new">) {
|
||||
await requireSession("/addresses/new");
|
||||
const { lat, lng, address, next } = await searchParams;
|
||||
|
||||
const location = await getLocationServer();
|
||||
const cityUuid = location?.mode === "manual" ? location.cityUuid : null;
|
||||
const neighborhoodUuid = location?.mode === "manual" ? location.neighborhoodUuid : null;
|
||||
|
||||
// Only needed for the fallback city/neighborhood picker (no location cookie set yet)
|
||||
// — cheap enough to always fetch rather than branch server-side on cityUuid.
|
||||
const [cities, neighborhoods] = await Promise.all([getCities(), getNeighborhoods()]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
<div className="flex items-center justify-start gap-3 px-4 pt-4 pb-3">
|
||||
<Link href="/addresses/map" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">جزئیات آدرس</h1>
|
||||
</div>
|
||||
|
||||
<AddressDetailsForm
|
||||
fullAddress={typeof address === "string" ? address : ""}
|
||||
latitude={typeof lat === "string" ? Number(lat) : 0}
|
||||
longitude={typeof lng === "string" ? Number(lng) : 0}
|
||||
cityUuid={cityUuid}
|
||||
neighborhoodUuid={neighborhoodUuid}
|
||||
cities={cities}
|
||||
neighborhoods={neighborhoods}
|
||||
next={typeof next === "string" ? next : null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,16 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getAddresses } from "@/lib/api/addresses";
|
||||
import { AddressSelector } from "@/components/customer/address-selector";
|
||||
import { getCities, getNeighborhoods } from "@/lib/api/locations";
|
||||
import { AddressList } from "@/components/customer/address-list";
|
||||
|
||||
export default async function AddressesPage({ searchParams }: PageProps<"/addresses">) {
|
||||
export default async function AddressesPage() {
|
||||
await requireSession("/addresses");
|
||||
const [addresses, { next }] = await Promise.all([getAddresses(), searchParams]);
|
||||
const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col gap-3">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
<div className="flex items-center justify-start gap-3 px-4 pt-4 pb-3">
|
||||
<Link href="/" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">آدرسهای من</h1>
|
||||
</div>
|
||||
|
||||
<AddressSelector addresses={addresses} next={typeof next === "string" ? next : null} />
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">آدرسهای من</h1>
|
||||
<AddressList addresses={addresses} cities={cities} neighborhoods={neighborhoods} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getCart } from "@/lib/api/cart";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { StoreCartCard } from "@/components/customer/store-cart-card";
|
||||
import { CartItemRow } from "@/components/customer/cart-item-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default async function CartPage() {
|
||||
await requireSession("/cart");
|
||||
const cart = await getCart();
|
||||
const hasBlockedGroup = cart.groups.some((g) => !g.meets_minimum_order);
|
||||
|
||||
if (cart.groups.length === 0) {
|
||||
return (
|
||||
|
|
@ -20,69 +20,55 @@ export default async function CartPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const totalItemLines = cart.groups.reduce(
|
||||
(sum, g) => sum + g.items.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-28">
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<Link href="/" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">
|
||||
سبد خرید
|
||||
</h1>
|
||||
</div>
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">سبد خرید</h1>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{cart.groups.map((group) => (
|
||||
<StoreCartCard key={group.store.uuid} group={group}>
|
||||
{!group.meets_minimum_order && (
|
||||
<p className="mt-3 rounded-lg bg-error/10 px-3 py-2 text-xs text-error">
|
||||
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
|
||||
</p>
|
||||
)}
|
||||
{cart.groups.map((group) => (
|
||||
<section key={group.store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
|
||||
<Link href={`/stores/${group.store.uuid}`} className="font-medium">
|
||||
{group.store.name}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={
|
||||
group.meets_minimum_order
|
||||
? `/checkout/${group.store.uuid}`
|
||||
: "#"
|
||||
}
|
||||
aria-disabled={!group.meets_minimum_order}
|
||||
className="mt-4 flex justify-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!group.meets_minimum_order}
|
||||
className="w-full rounded-xl bg-primary-action px-8 py-3 text-[14px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
تکمیل سفارش
|
||||
</button>
|
||||
</Link>
|
||||
</StoreCartCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 flex w-full flex-col items-center">
|
||||
<div className="flex w-full flex-row justify-between border-t border-gray-200 bg-white px-8 pt-6 pb-6">
|
||||
<div className="flex flex-col items-start justify-start gap-2">
|
||||
<span className="text-[14px] font-semibold text-foreground">
|
||||
جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "}
|
||||
{totalItemLines.toLocaleString("fa-IR")} کالا)
|
||||
</span>
|
||||
|
||||
<p className="text-end text-[12px] leading-normal text-gray-600">
|
||||
هر فروشگاه جداگانه پرداخت و ارسال میشود
|
||||
</p>
|
||||
<div className="divide-y divide-border">
|
||||
{group.items.map((item) => (
|
||||
<CartItemRow key={item.uuid} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||
{cart.grand_total.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 text-sm">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>جمع کالاها</span>
|
||||
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>هزینه ارسال</span>
|
||||
<span>{group.delivery_fee.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>مجموع</span>
|
||||
<span>{group.total.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!group.meets_minimum_order && (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="sticky bottom-4 flex flex-col gap-2 rounded-2xl border border-border bg-background p-4 shadow-lg">
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<Link href={hasBlockedGroup ? "#" : "/checkout"} aria-disabled={hasBlockedGroup}>
|
||||
<Button size="lg" className="w-full" disabled={hasBlockedGroup}>
|
||||
ادامه و ثبت سفارش
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getCart } from "@/lib/api/cart";
|
||||
import { getAddresses } from "@/lib/api/addresses";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { CheckoutAddress } from "@/components/customer/checkout-address";
|
||||
import { CheckoutPayment } from "@/components/customer/checkout-payment";
|
||||
|
||||
export default async function ConfirmPaymentPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PageProps<"/checkout/[store]/confirm">) {
|
||||
const { store } = await params;
|
||||
const { fulfillment } = await searchParams;
|
||||
const isDelivery = fulfillment === "delivery";
|
||||
await requireSession(`/checkout/${store}/confirm`);
|
||||
|
||||
const [cart, addresses] = await Promise.all([
|
||||
getCart(),
|
||||
// Known gap (FRONTEND_GUIDE.md §7): addresses GET can 403 on the current OAuth
|
||||
// scope — fall back to empty rather than crashing this page over it.
|
||||
getAddresses().catch(() => []),
|
||||
]);
|
||||
const group = cart.groups.find((g) => g.store.uuid === store);
|
||||
if (!group) notFound();
|
||||
|
||||
const address = addresses.find((a) => a.is_default) ?? addresses[0] ?? null;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
|
||||
{/* Same header pattern as /cart and /checkout/[store]. */}
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<Link href={`/checkout/${store}`} aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">تأیید و پرداخت</h1>
|
||||
</div>
|
||||
|
||||
{isDelivery && (
|
||||
<CheckoutAddress address={address} returnTo={`/checkout/${store}/confirm?fulfillment=delivery`} />
|
||||
)}
|
||||
|
||||
<CheckoutPayment group={group} addresses={addresses} isDelivery={isDelivery} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getCart } from "@/lib/api/cart";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { StoreCartCard } from "@/components/customer/store-cart-card";
|
||||
import { CheckoutFulfillment } from "@/components/customer/checkout-fulfillment";
|
||||
|
||||
export default async function StoreCheckoutPage({ params }: PageProps<"/checkout/[store]">) {
|
||||
const { store } = await params;
|
||||
await requireSession(`/checkout/${store}`);
|
||||
const cart = await getCart();
|
||||
const group = cart.groups.find((g) => g.store.uuid === store);
|
||||
if (!group) notFound();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
|
||||
{/* Same header as /cart, same DOM-order note: first child renders right, last left. */}
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<Link href="/cart" aria-label="بازگشت">
|
||||
<ArrowRight className="h-5 w-5 text-foreground" />
|
||||
</Link>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">سبد خرید</h1>
|
||||
</div>
|
||||
|
||||
<StoreCartCard group={group} />
|
||||
|
||||
<CheckoutFulfillment group={group} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
19
src/app/(customer)/(shop)/checkout/page.tsx
Normal file
19
src/app/(customer)/(shop)/checkout/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { getCart } from "@/lib/api/cart";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getAddresses } from "@/lib/api/addresses";
|
||||
import { CheckoutForm } from "@/components/customer/checkout-form";
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
await requireSession("/checkout");
|
||||
const [cart, addresses] = await Promise.all([getCart(), getAddresses()]);
|
||||
|
||||
if (cart.groups.length === 0) redirect("/cart");
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">تکمیل سفارش</h1>
|
||||
<CheckoutForm cart={cart} addresses={addresses} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,8 +14,8 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
|
|||
if (!product) notFound();
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col bg-white pb-35">
|
||||
<div className="relative h-80 w-full shrink-0 overflow-hidden bg-muted">
|
||||
<main className="flex min-h-full flex-1 flex-col bg-white pb-[140px]">
|
||||
<div className="relative h-[320px] w-full shrink-0 overflow-hidden bg-muted">
|
||||
{product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
||||
|
|
@ -24,9 +24,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 px-5 pt-5">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-[24px] font-bold leading-none text-foreground">{product.name}</h1>
|
||||
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-medium leading-none text-gray-600">
|
||||
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-semibold leading-none text-primary-action">
|
||||
{product.store.name}
|
||||
</Link>
|
||||
<p className="text-headline-medium font-extrabold leading-none text-primary-action">
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import ProfileListMenu from "@/components/profile/profile-menu";
|
||||
import ProfilePageHeadLine from "@/components/profile/profile-page-head-line";
|
||||
import ProfilePersonDetailCard from "@/components/profile/profile-person-detail-card";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <section>
|
||||
<ProfilePageHeadLine title='پروفایل من' />
|
||||
<ProfilePersonDetailCard />
|
||||
<ProfileListMenu />
|
||||
</section>
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import ProfilePageHeadLine from '@/components/profile/profile-page-head-line'
|
||||
import TransactionHistory from '@/components/wallet/transaction-history'
|
||||
import WalletBlancCard from '@/components/wallet/wallet-blanc-card'
|
||||
|
||||
const WalletPage = () => {
|
||||
return (
|
||||
<section>
|
||||
<ProfilePageHeadLine title='کیف پول من' />
|
||||
<WalletBlancCard />
|
||||
<ProfilePageHeadLine title='تاریخچه تراکنشها' />
|
||||
<TransactionHistory />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default WalletPage
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getAccount } from "@/lib/api/account";
|
||||
import { AccountProvider } from "@/lib/providers/account-provider";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
await requireSession("/login");
|
||||
|
||||
let account: Awaited<ReturnType<typeof getAccount>> | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
account = await getAccount();
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? "خطا در دریافت اطلاعات پروفایل";
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl w-full p-4 mx-auto">
|
||||
<AccountProvider account={account} error={error}>
|
||||
{children}
|
||||
</AccountProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
src/app/(customer)/(shop)/profile/page.tsx
Normal file
23
src/app/(customer)/(shop)/profile/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Link from "next/link";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { LogoutButton } from "@/components/customer/logout-button";
|
||||
|
||||
// Minimal placeholder — winofyfrontenddoc.md's bottom-nav references a "پروفایل"
|
||||
// tab, but no profile screen is documented yet in either doc.
|
||||
export default async function ProfilePage() {
|
||||
await requireSession("/profile");
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-3 px-4 py-6">
|
||||
<h1 className="text-title-large font-extrabold text-foreground">پروفایل</h1>
|
||||
<Link href="/orders" className="rounded-xl border border-border p-4 text-body-large">
|
||||
سفارشهای من
|
||||
</Link>
|
||||
<Link href="/addresses" className="rounded-xl border border-border p-4 text-body-large">
|
||||
آدرسهای من
|
||||
</Link>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,70 +25,45 @@ export default async function StoreDetailPage({
|
|||
});
|
||||
if (!store) notFound();
|
||||
|
||||
const [products, session] = await Promise.all([
|
||||
getProducts({ store: uuid, search, limit: 100 }),
|
||||
getValidSession(),
|
||||
]);
|
||||
const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]);
|
||||
const cart = session ? await getCart() : null;
|
||||
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
|
||||
|
||||
const cartItemByProduct = new Map<string, CartItem>();
|
||||
storeGroup?.items.forEach((item) =>
|
||||
cartItemByProduct.set(item.product.uuid, item),
|
||||
);
|
||||
storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item));
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
|
||||
<div className="relative h-64.25 w-full shrink-0 overflow-hidden rounded-b-2xl bg-muted">
|
||||
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
|
||||
{store.cover_image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={store.cover_image_url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
|
||||
<HeroBackButton href="/" />
|
||||
</div>
|
||||
<div className="w-full h-auto flex flex-col items-center">
|
||||
<StoreInfoCard store={store} />
|
||||
</div>
|
||||
|
||||
<section className="p-6">
|
||||
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">
|
||||
توضیحات
|
||||
</h2>
|
||||
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">
|
||||
{store.description}
|
||||
</p>
|
||||
<StoreInfoCard store={store} />
|
||||
|
||||
<section className="px-4 pt-2">
|
||||
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
|
||||
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
|
||||
</section>
|
||||
|
||||
<div className="pb-6">
|
||||
<div className="pt-4">
|
||||
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-30">
|
||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
|
||||
{products.map((product) => (
|
||||
<ProductGridCard
|
||||
key={product.uuid}
|
||||
product={product}
|
||||
cartItem={cartItemByProduct.get(product.uuid) ?? null}
|
||||
/>
|
||||
<ProductGridCard key={product.uuid} product={product} cartItem={cartItemByProduct.get(product.uuid) ?? null} />
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">
|
||||
محصولی یافت نشد.
|
||||
</p>
|
||||
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">محصولی یافت نشد.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{storeGroup && (
|
||||
<StickyCartBar
|
||||
itemCount={storeGroup.items.length}
|
||||
total={storeGroup.total}
|
||||
/>
|
||||
)}
|
||||
{storeGroup && <StickyCartBar itemCount={storeGroup.items.length} total={storeGroup.total} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ export const metadata: Metadata = {
|
|||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`} suppressHydrationWarning>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground" suppressHydrationWarning>
|
||||
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function RoleCard({
|
|||
className={cn(
|
||||
"flex h-12 w-full items-center justify-center rounded-full text-body-large font-semibold transition-colors",
|
||||
ctaVariant === "primary"
|
||||
? "border-0 bg-primary-action text-white hover:bg-primary-action/90"
|
||||
? "bg-primary-action text-white hover:bg-primary-action/90"
|
||||
: "border border-primary-action bg-white text-primary-action hover:bg-primary-action/5",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
54
src/components/customer/add-to-cart-button.tsx
Normal file
54
src/components/customer/add-to-cart-button.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { addToCart } from "@/lib/api/cart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export function AddToCartButton({
|
||||
productUuid,
|
||||
quantity = 1,
|
||||
disabled,
|
||||
outOfStock,
|
||||
className,
|
||||
}: {
|
||||
productUuid: string;
|
||||
quantity?: number;
|
||||
disabled?: boolean;
|
||||
outOfStock?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [feedback, setFeedback] = useState<"added" | "error" | null>(null);
|
||||
|
||||
function handleClick() {
|
||||
startTransition(async () => {
|
||||
const result = await addToCart(productUuid, quantity);
|
||||
if (result.ok) {
|
||||
setFeedback("added");
|
||||
router.refresh();
|
||||
setTimeout(() => setFeedback(null), 1500);
|
||||
} else if (result.status === 401) {
|
||||
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
} else {
|
||||
setFeedback("error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={feedback === "added" ? "secondary" : "primary"}
|
||||
disabled={disabled || isPending}
|
||||
onClick={handleClick}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
{outOfStock ? "ناموجود" : isPending ? "..." : feedback === "added" ? "افزوده شد ✓" : feedback === "error" ? "خطا، دوباره تلاش کنید" : "افزودن به سبد"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createAddress, type AddressInput } from "@/lib/api/addresses";
|
||||
import type { AddressLabel, City, Neighborhood } from "@/types/api";
|
||||
|
||||
const LABEL_PRESETS: { value: AddressLabel; text: string }[] = [
|
||||
{ value: "other", text: "سایر" },
|
||||
{ value: "work", text: "محل کار" },
|
||||
{ value: "home", text: "خانه جدید" },
|
||||
];
|
||||
|
||||
export function AddressDetailsForm({
|
||||
fullAddress,
|
||||
latitude,
|
||||
longitude,
|
||||
cityUuid,
|
||||
neighborhoodUuid,
|
||||
cities,
|
||||
neighborhoods,
|
||||
next,
|
||||
}: {
|
||||
fullAddress: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** Inferred from the customer's current location cookie, if they'd set one manually. */
|
||||
cityUuid: string | null;
|
||||
neighborhoodUuid: string | null;
|
||||
cities: City[];
|
||||
neighborhoods: Neighborhood[];
|
||||
/** Set when this add-address detour started from checkout — see handleSubmit. */
|
||||
next: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [address, setAddress] = useState(fullAddress);
|
||||
const [label, setLabel] = useState<AddressLabel>("home");
|
||||
const [title, setTitle] = useState("خانه جدید");
|
||||
const [plaque, setPlaque] = useState("");
|
||||
const [floor, setFloor] = useState("");
|
||||
const [unit, setUnit] = useState("");
|
||||
const [recipientName, setRecipientName] = useState("");
|
||||
const [recipientPhone, setRecipientPhone] = useState("");
|
||||
const [manualCityUuid, setManualCityUuid] = useState(cityUuid ?? "");
|
||||
const [manualNeighborhoodUuid, setManualNeighborhoodUuid] = useState(neighborhoodUuid ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// city_uuid is a hard requirement on the backend (OpenAPI: Address.city_uuid is
|
||||
// required for writes) but isn't part of this screen's design — it's normally
|
||||
// inferred from the customer's current location cookie. Only show a picker for
|
||||
// it when that inference failed (e.g. they're in "geo" mode, or never set one).
|
||||
const needsCityPicker = !cityUuid;
|
||||
const filteredNeighborhoods = useMemo(
|
||||
() => neighborhoods.filter((n) => n.city.uuid === manualCityUuid),
|
||||
[neighborhoods, manualCityUuid],
|
||||
);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const finalCityUuid = cityUuid ?? manualCityUuid;
|
||||
if (!finalCityUuid) {
|
||||
setError("شهر مشخص نیست — لطفاً محله خود را از صفحه اصلی انتخاب کنید یا شهر را زیر مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const input: AddressInput = {
|
||||
label,
|
||||
title,
|
||||
full_address: address,
|
||||
plaque,
|
||||
floor,
|
||||
unit,
|
||||
recipient_name: recipientName,
|
||||
recipient_phone: recipientPhone,
|
||||
city_uuid: finalCityUuid,
|
||||
neighborhood_uuid: (neighborhoodUuid ?? manualNeighborhoodUuid) || undefined,
|
||||
latitude,
|
||||
longitude,
|
||||
// Arriving here from checkout means they had no address at all yet — make
|
||||
// this one the default immediately so it's actually used for the order,
|
||||
// rather than sending them on to /addresses to select it a second time.
|
||||
// A plain profile-driven "add another address" leaves the existing default alone.
|
||||
is_default: next !== null,
|
||||
};
|
||||
const result = await createAddress(input);
|
||||
if (result.ok) {
|
||||
router.push(next ?? "/addresses");
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectPreset(preset: (typeof LABEL_PRESETS)[number]) {
|
||||
setLabel(preset.value);
|
||||
setTitle(preset.text);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 px-4 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">آدرس کامل</span>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
required
|
||||
className="h-12 rounded-xl border border-gray-300 bg-white px-4 text-[14px] text-foreground outline-none focus:border-primary-action"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">عنوان آدرس</span>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{LABEL_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => selectPreset(preset)}
|
||||
className={`rounded-full border px-4 py-2 text-[13px] font-semibold whitespace-nowrap ${
|
||||
label === preset.value
|
||||
? "border-primary-action bg-blue-bg text-primary-action"
|
||||
: "border-gray-300 bg-white text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{preset.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">پلاک</span>
|
||||
<input
|
||||
type="text"
|
||||
value={plaque}
|
||||
onChange={(e) => setPlaque(e.target.value)}
|
||||
className="h-12 rounded-xl border border-gray-300 bg-white px-3 text-center text-[14px] text-foreground outline-none focus:border-primary-action"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">طبقه</span>
|
||||
<input
|
||||
type="text"
|
||||
value={floor}
|
||||
onChange={(e) => setFloor(e.target.value)}
|
||||
className="h-12 rounded-xl border border-gray-300 bg-white px-3 text-center text-[14px] text-foreground outline-none focus:border-primary-action"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">واحد</span>
|
||||
<input
|
||||
type="text"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
className="h-12 rounded-xl border border-gray-300 bg-white px-3 text-center text-[14px] text-foreground outline-none focus:border-primary-action"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">نام گیرنده</span>
|
||||
<input
|
||||
type="text"
|
||||
value={recipientName}
|
||||
onChange={(e) => setRecipientName(e.target.value)}
|
||||
required
|
||||
className="h-12 rounded-xl border border-gray-300 bg-white px-4 text-[14px] text-foreground outline-none focus:border-primary-action"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-semibold leading-none text-gray-700">شماره تماس</span>
|
||||
<input
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
value={recipientPhone}
|
||||
onChange={(e) => setRecipientPhone(e.target.value)}
|
||||
placeholder="09123456789"
|
||||
required
|
||||
className="h-12 rounded-xl border border-gray-300 bg-white px-4 text-[14px] text-foreground outline-none placeholder:text-gray-400 focus:border-primary-action"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsCityPicker && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-gray-300 bg-gray-50 p-4">
|
||||
<p className="text-[12px] leading-normal text-gray-700">
|
||||
شهر شما مشخص نیست — این فقط لازم است چون هنوز محلهای از صفحه اصلی انتخاب نکردهاید.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={manualCityUuid}
|
||||
onChange={(e) => {
|
||||
setManualCityUuid(e.target.value);
|
||||
setManualNeighborhoodUuid("");
|
||||
}}
|
||||
className="h-11 rounded-xl border border-gray-300 bg-white px-3 text-[13px]"
|
||||
required
|
||||
>
|
||||
<option value="">شهر</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city.uuid} value={city.uuid}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={manualNeighborhoodUuid}
|
||||
onChange={(e) => setManualNeighborhoodUuid(e.target.value)}
|
||||
className="h-11 rounded-xl border border-gray-300 bg-white px-3 text-[13px]"
|
||||
disabled={!manualCityUuid}
|
||||
>
|
||||
<option value="">محله (اختیاری)</option>
|
||||
{filteredNeighborhoods.map((n) => (
|
||||
<option key={n.uuid} value={n.uuid}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-[13px] text-error">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="mt-auto w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "در حال ذخیره..." : "ذخیره آدرس"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
198
src/components/customer/address-list.tsx
Normal file
198
src/components/customer/address-list.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createAddress, deleteAddress, type AddressInput } from "@/lib/api/addresses";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Address, AddressLabel, City, Neighborhood } from "@/types/api";
|
||||
|
||||
const LABEL_OPTIONS: { value: AddressLabel; label: string }[] = [
|
||||
{ value: "home", label: "خانه" },
|
||||
{ value: "work", label: "محل کار" },
|
||||
{ value: "other", label: "سایر" },
|
||||
];
|
||||
|
||||
const emptyForm = {
|
||||
label: "home" as AddressLabel,
|
||||
title: "",
|
||||
full_address: "",
|
||||
plaque: "",
|
||||
floor: "",
|
||||
unit: "",
|
||||
recipient_name: "",
|
||||
recipient_phone: "",
|
||||
city_uuid: "",
|
||||
neighborhood_uuid: "",
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
export function AddressList({
|
||||
addresses,
|
||||
cities,
|
||||
neighborhoods,
|
||||
}: {
|
||||
addresses: Address[];
|
||||
cities: City[];
|
||||
neighborhoods: Neighborhood[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const filteredNeighborhoods = useMemo(
|
||||
() => neighborhoods.filter((n) => n.city.uuid === form.city_uuid),
|
||||
[neighborhoods, form.city_uuid],
|
||||
);
|
||||
|
||||
function handleDelete(uuid: string) {
|
||||
startTransition(async () => {
|
||||
await deleteAddress(uuid);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const input: AddressInput = {
|
||||
...form,
|
||||
neighborhood_uuid: form.neighborhood_uuid || undefined,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
};
|
||||
const result = await createAddress(input);
|
||||
if (result.ok) {
|
||||
setForm(emptyForm);
|
||||
setIsAdding(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{addresses.map((address) => (
|
||||
<div key={address.uuid} className="flex items-start justify-between gap-3 rounded-2xl border border-border p-4">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{LABEL_OPTIONS.find((o) => o.value === address.label)?.label} {address.is_default && "· پیشفرض"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{address.full_address}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{address.recipient_name} — {address.recipient_phone}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled={isPending} onClick={() => handleDelete(address.uuid)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{addresses.length === 0 && !isAdding && <p className="text-sm text-muted-foreground">آدرسی ثبت نشده است.</p>}
|
||||
</div>
|
||||
|
||||
{isAdding ? (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border p-4">
|
||||
<div className="flex gap-2">
|
||||
{LABEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, label: opt.value }))}
|
||||
className={`rounded-full px-3 py-1 text-xs ${form.label === opt.value ? "bg-primary text-primary-foreground" : "border border-border"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="آدرس کامل"
|
||||
value={form.full_address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, full_address: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input placeholder="پلاک" value={form.plaque} onChange={(e) => setForm((f) => ({ ...f, plaque: e.target.value }))} />
|
||||
<Input placeholder="طبقه" value={form.floor} onChange={(e) => setForm((f) => ({ ...f, floor: e.target.value }))} />
|
||||
<Input placeholder="واحد" value={form.unit} onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={form.city_uuid}
|
||||
onChange={(e) => setForm((f) => ({ ...f, city_uuid: e.target.value, neighborhood_uuid: "" }))}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
required
|
||||
>
|
||||
<option value="">شهر</option>
|
||||
{cities.map((city) => (
|
||||
<option key={city.uuid} value={city.uuid}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={form.neighborhood_uuid}
|
||||
onChange={(e) => setForm((f) => ({ ...f, neighborhood_uuid: e.target.value }))}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
disabled={!form.city_uuid}
|
||||
>
|
||||
<option value="">محله (اختیاری)</option>
|
||||
{filteredNeighborhoods.map((n) => (
|
||||
<option key={n.uuid} value={n.uuid}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="نام گیرنده"
|
||||
value={form.recipient_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recipient_name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
placeholder="09123456789"
|
||||
value={form.recipient_phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, recipient_phone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm((f) => ({ ...f, is_default: e.target.checked }))}
|
||||
/>
|
||||
آدرس پیشفرض
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "در حال ثبت..." : "ثبت آدرس"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setIsAdding(true)}>
|
||||
+ افزودن آدرس جدید
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateAddress } from "@/lib/api/addresses";
|
||||
import type { Address, AddressLabel } from "@/types/api";
|
||||
|
||||
const ADDRESS_LABEL_FA: Record<AddressLabel, string> = {
|
||||
home: "خانه",
|
||||
work: "محل کار",
|
||||
other: "سایر",
|
||||
};
|
||||
|
||||
/**
|
||||
* Doubles as both a plain address-management screen (reached from the profile menu)
|
||||
* and a picker (reached from checkout's "تغییر آدرس" with a `next` return path) —
|
||||
* the radio always reflects/sets which address is the default, "تأیید" just persists
|
||||
* that choice and, if there's somewhere to return to, goes back there.
|
||||
*/
|
||||
export function AddressSelector({ addresses, next }: { addresses: Address[]; next: string | null }) {
|
||||
const router = useRouter();
|
||||
const [selectedUuid, setSelectedUuid] = useState(
|
||||
() => addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid ?? null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleConfirm() {
|
||||
setError(null);
|
||||
const currentDefaultUuid = addresses.find((a) => a.is_default)?.uuid;
|
||||
if (!selectedUuid || selectedUuid === currentDefaultUuid) {
|
||||
if (next) router.push(next);
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await updateAddress(selectedUuid, { is_default: true });
|
||||
if (result.ok) {
|
||||
if (next) router.push(next);
|
||||
else router.refresh();
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 px-4 pb-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
{addresses.map((address) => {
|
||||
const isSelected = address.uuid === selectedUuid;
|
||||
return (
|
||||
<button
|
||||
key={address.uuid}
|
||||
type="button"
|
||||
onClick={() => setSelectedUuid(address.uuid)}
|
||||
className={`flex flex-col gap-1 rounded-2xl border p-4 text-right ${
|
||||
isSelected ? "border-primary-action" : "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout —
|
||||
see the inherited dir="rtl" note on the /cart page. */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">
|
||||
{address.title || ADDRESS_LABEL_FA[address.label]}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${
|
||||
isSelected ? "border-primary-action" : "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] leading-normal text-gray-600">{address.full_address}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{addresses.length === 0 && (
|
||||
<p className="py-4 text-center text-[13px] text-gray-600">هنوز آدرسی ثبت نکردهاید.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(next ? `/addresses/map?next=${encodeURIComponent(next)}` : "/addresses/map")}
|
||||
className="rounded-2xl border border-primary-action py-4 text-[14px] font-bold text-primary-action"
|
||||
>
|
||||
افزودن آدرس جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[13px] text-error">{error}</p>}
|
||||
|
||||
{addresses.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
className="mt-auto w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "در حال ذخیره..." : "تأیید"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,92 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Minus, Plus, Trash2 } from "lucide-react";
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
|
||||
import type { CartItem } from "@/types/api";
|
||||
|
||||
export function CartItemRow({ item }: { item: CartItem }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const timeout = setTimeout(() => setError(null), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [error]);
|
||||
|
||||
function changeQuantity(nextQuantity: number) {
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
nextQuantity <= 0 ? await removeCartItem(item.uuid) : await updateCartItemQuantity(item.uuid, nextQuantity);
|
||||
|
||||
if (result.ok) {
|
||||
router.refresh();
|
||||
} else if (result.status === 401) {
|
||||
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
if (nextQuantity <= 0) {
|
||||
await removeCartItem(item.uuid);
|
||||
} else {
|
||||
setError(result.message);
|
||||
await updateCartItemQuantity(item.uuid, nextQuantity);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 py-4 ${isPending ? "opacity-50" : ""}`}>
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
|
||||
order — under this app's inherited dir="rtl", the first child renders at the
|
||||
right edge and the last at the left. */}
|
||||
<div className="flex items-start justify-start gap-3">
|
||||
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||
{item.product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 text-right m-2">
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">{item.product.name}</span>
|
||||
<span className="text-[13px] leading-none text-gray-600">
|
||||
{item.product.price.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||
{item.product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<div className="flex h-8 items-center gap-3 rounded-lg border border-gray-300 px-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity - 1)}
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5 text-foreground" />
|
||||
</button>
|
||||
<span className="min-w-3 text-center text-[13px] font-bold leading-none text-foreground">
|
||||
{item.quantity.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity + 1)}
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-sm font-medium">{item.product.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(0)}
|
||||
aria-label="حذف از سبد"
|
||||
className="flex h-8 w-8 items-center justify-center disabled:opacity-40"
|
||||
onClick={() => changeQuantity(item.quantity - 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
<Trash2 className="h-4.5 w-4.5 text-error" />
|
||||
−
|
||||
</button>
|
||||
<span className="w-5 text-center text-sm">{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(item.quantity + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-end text-xs text-error">{error}</p>}
|
||||
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { MapPin } from "lucide-react";
|
||||
import type { Address, AddressLabel } from "@/types/api";
|
||||
|
||||
const ADDRESS_LABEL_FA: Record<AddressLabel, string> = {
|
||||
home: "خانه",
|
||||
work: "محل کار",
|
||||
other: "سایر",
|
||||
};
|
||||
|
||||
/**
|
||||
* Both actions carry `returnTo` (the checkout confirm page's own URL) so the whole
|
||||
* address-management chain — map → details form → address list — comes back here
|
||||
* instead of stranding the customer on /addresses mid-checkout.
|
||||
*/
|
||||
export function CheckoutAddress({
|
||||
address,
|
||||
returnTo,
|
||||
}: {
|
||||
address: Address | null;
|
||||
returnTo: string;
|
||||
}) {
|
||||
const next = encodeURIComponent(returnTo);
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-[15px] font-bold leading-none text-foreground">
|
||||
آدرس تحویل
|
||||
</h2>
|
||||
|
||||
{address ? (
|
||||
<div className="flex justify-between gap-3 rounded-2xl border border-gray-300 p-4">
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout — see the
|
||||
inherited dir="rtl" note on the /cart page. */}
|
||||
<div className="flex flex-col justify-start items-start gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-4 w-4 text-primary-action" />
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">
|
||||
{address.title || ADDRESS_LABEL_FA[address.label]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-end text-[13px] leading-normal text-gray-600">
|
||||
{address.full_address}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/addresses?next=${next}`}
|
||||
className="text-[13px] font-semibold leading-none text-primary-action"
|
||||
>
|
||||
تغییر آدرس
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-gray-300 p-6 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<MapPin className="h-5 w-5 text-gray-500" />
|
||||
</span>
|
||||
<p className="text-[13px] leading-normal text-gray-600">
|
||||
هنوز آدرسی ثبت نکردهاید. برای ادامه سفارش، لطفاً یک آدرس اضافه
|
||||
کنید.
|
||||
</p>
|
||||
<Link href={`/addresses/map?next=${next}`} className="w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-xl bg-primary-action py-3 text-[14px] font-bold text-white"
|
||||
>
|
||||
افزودن آدرس
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
158
src/components/customer/checkout-form.tsx
Normal file
158
src/components/customer/checkout-form.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { submitCheckout } from "@/lib/api/checkout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Address, Cart, DeliveryType, PaymentMethod } from "@/types/api";
|
||||
|
||||
const PAYMENT_LABELS_FA: Record<PaymentMethod, string> = {
|
||||
wallet: "کیف پول وینوفای",
|
||||
online: "درگاه بانکی",
|
||||
cash_on_delivery: "پرداخت در محل",
|
||||
};
|
||||
|
||||
export function CheckoutForm({ cart, addresses }: { cart: Cart; addresses: Address[] }) {
|
||||
const router = useRouter();
|
||||
const [addressUuid, setAddressUuid] = useState(addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid ?? "");
|
||||
const [deliveryType, setDeliveryType] = useState<DeliveryType>("express");
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("cash_on_delivery");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!addressUuid) {
|
||||
setError("لطفاً یک آدرس انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
if (deliveryType === "scheduled" && !scheduledAt) {
|
||||
setError("لطفاً زمان ارسال را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await submitCheckout({
|
||||
address_uuid: addressUuid,
|
||||
delivery_type: deliveryType,
|
||||
scheduled_at: deliveryType === "scheduled" ? new Date(scheduledAt).toISOString() : null,
|
||||
payment_method: paymentMethod,
|
||||
notes,
|
||||
});
|
||||
if (result.ok) {
|
||||
router.push(`/order-groups/${result.data.uuid}`);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">برای ثبت سفارش ابتدا یک آدرس اضافه کنید.</p>
|
||||
<Link href="/addresses">
|
||||
<Button>افزودن آدرس</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">آدرس تحویل</h2>
|
||||
{addresses.map((address) => (
|
||||
<label
|
||||
key={address.uuid}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-2xl border p-3 text-sm ${addressUuid === address.uuid ? "border-primary" : "border-border"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="address"
|
||||
checked={addressUuid === address.uuid}
|
||||
onChange={() => setAddressUuid(address.uuid)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{address.title || address.full_address}</span>
|
||||
<span className="block text-muted-foreground">{address.full_address}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<Link href="/addresses" className="text-xs text-primary underline underline-offset-4">
|
||||
مدیریت آدرسها
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">زمان ارسال</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryType("express")}
|
||||
className={`flex-1 rounded-xl border px-3 py-2 text-sm ${deliveryType === "express" ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
>
|
||||
ارسال فوری
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeliveryType("scheduled")}
|
||||
className={`flex-1 rounded-xl border px-3 py-2 text-sm ${deliveryType === "scheduled" ? "border-primary bg-primary/10" : "border-border"}`}
|
||||
>
|
||||
زمانبندی شده
|
||||
</button>
|
||||
</div>
|
||||
{deliveryType === "scheduled" && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">روش پرداخت</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(PAYMENT_LABELS_FA) as PaymentMethod[]).map((method) => (
|
||||
<label
|
||||
key={method}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-2xl border p-3 text-sm ${paymentMethod === method ? "border-primary" : "border-border"}`}
|
||||
>
|
||||
<input type="radio" name="payment" checked={paymentMethod === method} onChange={() => setPaymentMethod(method)} />
|
||||
{PAYMENT_LABELS_FA[method]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">توضیحات (اختیاری)</h2>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between rounded-2xl border border-border p-4 font-semibold">
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" disabled={isPending}>
|
||||
{isPending ? "در حال ثبت سفارش..." : "ثبت نهایی سفارش"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { CartGroup } from "@/types/api";
|
||||
|
||||
/**
|
||||
* "دریافت حضوری" vs "ارسال توسط فروشگاه" has no backing field in the Checkout API —
|
||||
* the OpenAPI spec's Checkout schema is address_uuid/delivery_type/scheduled_at/
|
||||
* payment_method/notes only, with delivery_type meaning express-vs-scheduled timing,
|
||||
* not pickup-vs-delivery fulfillment. So this toggle only affects what's *displayed*
|
||||
* here; the actual order submission (next screen) still uses the real delivery fee.
|
||||
*/
|
||||
export function CheckoutFulfillment({ group }: { group: CartGroup }) {
|
||||
const [pickup, setPickup] = useState(false);
|
||||
const deliveryFee = pickup ? 0 : group.delivery_fee;
|
||||
const payable = group.items_subtotal + deliveryFee;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 pb-32">
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-[15px] font-bold leading-none text-foreground">نحوه دریافت</h2>
|
||||
|
||||
<label
|
||||
className={`flex cursor-pointer flex-col gap-1 rounded-2xl border p-4 ${pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="fulfillment"
|
||||
checked={pickup}
|
||||
onChange={() => setPickup(true)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout — see the
|
||||
inherited dir="rtl" note on the /cart page. */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">دریافت حضوری</span>
|
||||
<span className="text-[12px] leading-none text-gray-500">رایگان</span>
|
||||
</div>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||
>
|
||||
{pickup && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||
</span>
|
||||
</div>
|
||||
{/* The data for delivery time is mock, it should be provided from the backend*/}
|
||||
<span className="text-[12px] leading-none text-gray-500">آماده در ۱۵ دقیقه</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex cursor-pointer flex-col gap-1 rounded-2xl border p-4 ${!pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="fulfillment"
|
||||
checked={!pickup}
|
||||
onChange={() => setPickup(false)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">ارسال توسط فروشگاه</span>
|
||||
<span className="text-[12px] leading-none text-gray-500">
|
||||
{group.delivery_fee.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${!pickup ? "border-primary-action" : "border-gray-300"}`}
|
||||
>
|
||||
{!pickup && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||
</span>
|
||||
</div>
|
||||
{/* The data for delivery time is mock, it should be provided from the backend*/}
|
||||
<span className="text-[12px] leading-none text-gray-500">۲۵-۳۵ دقیقه</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3 rounded-2xl bg-[#F4F4F5] px-4 py-5">
|
||||
<div className="flex items-center justify-between text-[13px] text-gray-600">
|
||||
<span>جمع کالاها</span>
|
||||
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[13px] text-gray-600">
|
||||
<span>هزینه ارسال</span>
|
||||
<span>{deliveryFee.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-300 pt-3">
|
||||
<span className="text-[14px] font-semibold leading-none text-foreground">مبلغ قابل پرداخت</span>
|
||||
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||
{payable.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-md flex-col items-center bg-white px-4 pt-4 pb-6 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||
{/* Same confirm+pay screen either way — it shows the address section only for
|
||||
delivery, and folds the delivery fee into the total only for delivery. */}
|
||||
<Link
|
||||
href={`/checkout/${group.store.uuid}/confirm?fulfillment=${pickup ? "pickup" : "delivery"}`}
|
||||
className="w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white"
|
||||
>
|
||||
پرداخت و ثبت سفارش — {payable.toLocaleString("fa-IR")} تومان
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { submitCheckout } from "@/lib/api/checkout";
|
||||
import type { Address, CartGroup, PaymentMethod } from "@/types/api";
|
||||
|
||||
/** Exact labels per the OpenAPI PaymentMethodEnum description ("(آنلاین)" added to match the screenshot). */
|
||||
const PAYMENT_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||
{ value: "wallet", label: "کیف پول وینسو" },
|
||||
{ value: "online", label: "درگاه بانکی (آنلاین)" },
|
||||
{ value: "cash_on_delivery", label: "پرداخت در محل" },
|
||||
];
|
||||
|
||||
export function CheckoutPayment({
|
||||
group,
|
||||
addresses,
|
||||
isDelivery,
|
||||
}: {
|
||||
group: CartGroup;
|
||||
addresses: Address[];
|
||||
isDelivery: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [method, setMethod] = useState<PaymentMethod>("wallet");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Pickup — no delivery fee, matches the fulfillment screen's "دریافت حضوری" total.
|
||||
const payable = group.items_subtotal + (isDelivery ? group.delivery_fee : 0);
|
||||
|
||||
function handlePay() {
|
||||
setError(null);
|
||||
const addressUuid = addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid;
|
||||
if (!addressUuid) {
|
||||
setError("برای ثبت سفارش ابتدا یک آدرس اضافه کنید.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await submitCheckout({
|
||||
address_uuid: addressUuid,
|
||||
delivery_type: "express",
|
||||
scheduled_at: null,
|
||||
payment_method: method,
|
||||
notes: "",
|
||||
// Scopes this checkout to just this store's cart group — without this,
|
||||
// the backend checked out the entire cart regardless of which store's
|
||||
// "پرداخت" button was pressed.
|
||||
store_uuids: [group.store.uuid],
|
||||
});
|
||||
if (result.ok) {
|
||||
router.push(`/order-groups/${result.data.uuid}`);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 pb-44">
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-[15px] font-bold leading-none text-foreground">روش پرداخت</h2>
|
||||
|
||||
{PAYMENT_OPTIONS.map((option) => {
|
||||
const selected = method === option.value;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 ${selected ? "border-primary-action" : "border-gray-200"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
checked={selected}
|
||||
onChange={() => setMethod(option.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout — see
|
||||
the inherited dir="rtl" note on the /cart page. No wallet balance line:
|
||||
Winofy's API has no wallet-balance endpoint — that's a Gooyal-side
|
||||
feature (see the wallet.wallet:get_balance OAuth scope) with no
|
||||
documented endpoint available here, so it isn't faked. */}
|
||||
<span className="text-[14px] font-bold leading-none text-foreground">{option.label}</span>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${selected ? "border-primary-action" : "border-gray-300"}`}
|
||||
>
|
||||
{selected && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{error && <p className="text-end text-sm text-error">{error}</p>}
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-md flex-col gap-3 bg-white px-4 pt-4 pb-6 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||
<div className="flex items-center justify-between text-[13px] text-gray-600">
|
||||
<span>
|
||||
{group.items.length.toLocaleString("fa-IR")} کالا از {group.store.name}
|
||||
</span>
|
||||
<span>{payable.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-semibold leading-none text-foreground">جمع کل</span>
|
||||
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||
{payable.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePay}
|
||||
disabled={isPending}
|
||||
className="w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "در حال ثبت سفارش..." : `پرداخت ${payable.toLocaleString("fa-IR")} تومان`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { clearCart } from "@/lib/api/cart";
|
||||
|
||||
export function ClearCartButton() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const timeout = setTimeout(() => setError(null), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [error]);
|
||||
|
||||
function handleClear() {
|
||||
if (!window.confirm("سبد خرید شما بهطور کامل خالی میشود. ادامه میدهید؟")) return;
|
||||
startTransition(async () => {
|
||||
const result = await clearCart();
|
||||
if (result.ok) {
|
||||
router.refresh();
|
||||
} else if (result.status === 401) {
|
||||
router.push(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-error disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
خالی کردن سبد
|
||||
</button>
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { ShoppingBasket } from "lucide-react";
|
||||
import { CartItemRow } from "@/components/customer/cart-item-row";
|
||||
import type { CartGroup } from "@/types/api";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Shared between /cart and /checkout/[store] — the pixel-perfect per-store card. */
|
||||
export function StoreCartCard({ group, children }: { group: CartGroup; children?: ReactNode }) {
|
||||
return (
|
||||
<section className="relative rounded-2xl border border-gray-300 bg-white p-6 shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
||||
<span className="absolute -top-3 left-4 rounded-lg bg-blue-bg px-2.5 py-1 text-[12px] font-semibold leading-none text-primary-action">
|
||||
{group.items.length.toLocaleString("fa-IR")} کالا
|
||||
</span>
|
||||
|
||||
<Link
|
||||
href={`/stores/${group.store.uuid}`}
|
||||
className="mb-3 flex items-center justify-start gap-2 border-b border-b-[#E4E4E7] pb-5"
|
||||
>
|
||||
<ShoppingBasket className="h-4.5 w-4.5 text-foreground" />
|
||||
<span className="text-[16px] font-bold leading-none text-foreground">{group.store.name}</span>
|
||||
</Link>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{group.items.map((item) => (
|
||||
<CartItemRow key={item.uuid} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start border-t border-gray-200 pt-5 pb-1">
|
||||
<span className="text-[13px] font-semibold leading-none text-foreground">
|
||||
جمع فروشگاه: {group.items_subtotal.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { useState } from "react";
|
||||
import { MapContainer, TileLayer, ZoomControl, useMap, useMapEvents } from "react-leaflet";
|
||||
import { LocateFixed } from "lucide-react";
|
||||
|
||||
const ADDWIN_TILE_URL = "https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png";
|
||||
|
||||
/**
|
||||
* Reports the map's center whenever panning/zooming settles — this drives the
|
||||
* "pin fixed at screen-center, map moves underneath" UX (see the CSS pin in
|
||||
* AddressMapLeaflet below), not a draggable react-leaflet <Marker>.
|
||||
*/
|
||||
function CenterTracker({ onMove }: { onMove: (lat: number, lng: number) => void }) {
|
||||
useMapEvents({
|
||||
moveend: (e) => {
|
||||
const c = e.target.getCenter();
|
||||
onMove(c.lat, c.lng);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recenters the map on the browser's reported GPS position — same navigator.geolocation
|
||||
* pattern as the location-onboarding flow (permission/error screens). */
|
||||
function RecenterButton() {
|
||||
const map = useMap();
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
function handleClick() {
|
||||
setLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
map.flyTo([position.coords.latitude, position.coords.longitude], 16);
|
||||
setLocating(false);
|
||||
},
|
||||
() => setLocating(false),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={locating}
|
||||
aria-label="موقعیت فعلی من"
|
||||
className="absolute bottom-4 left-4 z-[1000] flex h-11 w-11 items-center justify-center rounded-full border-0 bg-white text-primary-action shadow-[0_2px_10px_-2px_rgba(0,0,0,0.2)] disabled:opacity-40"
|
||||
>
|
||||
<LocateFixed className={`h-5 w-5 ${locating ? "animate-pulse" : ""}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressMapLeaflet({
|
||||
initialCenter,
|
||||
onCenterChange,
|
||||
}: {
|
||||
initialCenter: [number, number];
|
||||
onCenterChange: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<MapContainer
|
||||
center={initialCenter}
|
||||
zoom={15}
|
||||
scrollWheelZoom={true}
|
||||
zoomControl={false}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© Addwin Maps"
|
||||
url={ADDWIN_TILE_URL}
|
||||
maxZoom={19}
|
||||
minZoom={8}
|
||||
/>
|
||||
<ZoomControl position="topleft" />
|
||||
<CenterTracker onMove={onCenterChange} />
|
||||
<RecenterButton />
|
||||
</MapContainer>
|
||||
|
||||
{/* Fixed screen-center pin — its tip (not its center) marks the selected point,
|
||||
so it's offset up by its own height rather than centered on both axes. */}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1000] flex items-center justify-center">
|
||||
<div className="-translate-y-full">
|
||||
<svg width="36" height="46" viewBox="0 0 36 46" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M18 0C8.06 0 0 8.06 0 18c0 13.5 18 28 18 28s18-14.5 18-28C36 8.06 27.94 0 18 0Z"
|
||||
fill="#F0642D"
|
||||
/>
|
||||
<circle cx="18" cy="18" r="7" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// Leaflet touches `window` at import time — this component can only ever run in
|
||||
// the browser, so it's dynamically imported with ssr:false from this client
|
||||
// component (Server Components/pages can't pass ssr:false themselves in the
|
||||
// App Router, hence this wrapper layer).
|
||||
const AddressMapLeaflet = dynamic(
|
||||
() => import("./address-map-leaflet").then((m) => m.AddressMapLeaflet),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-full w-full animate-pulse bg-gray-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
// Central Tehran — used only when there's no prior location to center on.
|
||||
const DEFAULT_CENTER: [number, number] = [35.7219, 51.339];
|
||||
|
||||
export function AddressMapPicker({ initialCenter }: { initialCenter: [number, number] | null }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const next = searchParams.get("next");
|
||||
const [center, setCenter] = useState(initialCenter ?? DEFAULT_CENTER);
|
||||
const [address, setAddress] = useState("");
|
||||
|
||||
function handleConfirm() {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(center[0]),
|
||||
lng: String(center[1]),
|
||||
address,
|
||||
});
|
||||
if (next) params.set("next", next);
|
||||
router.push(`/addresses/new?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col">
|
||||
<div className="h-[55vh] w-full shrink-0 overflow-hidden">
|
||||
<AddressMapLeaflet initialCenter={center} onCenterChange={(lat, lng) => setCenter([lat, lng])} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[12px] leading-none text-gray-600">موقعیت انتخابی</span>
|
||||
{/* No reverse-geocoding endpoint is documented for Addwin or Winofy (checked
|
||||
the OpenAPI spec — Address only ever takes latitude/longitude and a
|
||||
separate free-text full_address, never derives one from the other), so
|
||||
this is a real editable field styled to match the design, not an
|
||||
auto-filled label. */}
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="آدرس را وارد کنید — مثلاً خیابان، کوچه، پلاک"
|
||||
className="border-0 bg-transparent p-0 text-[15px] font-semibold leading-normal text-foreground outline-none placeholder:font-normal placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={!address.trim()}
|
||||
className="mt-auto w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
تأیید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useState, useTransition, type ReactNode } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Minus, Plus, X, CheckCircle2, Check } from "lucide-react";
|
||||
import { Minus, Plus, X, CheckCircle2 } from "lucide-react";
|
||||
import { addToCart } from "@/lib/api/cart";
|
||||
import { toPersianDigits } from "@/lib/format/persian-digits";
|
||||
import type { ProductDetail } from "@/types/api";
|
||||
|
|
@ -41,28 +41,8 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
|
||||
order — under this app's inherited dir="rtl", the first child renders at the
|
||||
right edge and the last at the left, so "what should appear on the left" must
|
||||
come last in JSX (stepper group, then its own "+" trailing last so it lands
|
||||
leftmost within the group). */}
|
||||
<div className="flex h-10 items-center justify-between">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}
|
||||
>
|
||||
{product.is_out_of_stock ? "ناموجود" : "موجود"}
|
||||
{!product.is_out_of_stock && <Check className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
|
|
@ -71,7 +51,19 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
|
|||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<span className={`text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}>
|
||||
{product.is_out_of_stock ? "ناموجود" : "موجود ●"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px w-full bg-gray-300" />
|
||||
|
|
@ -81,35 +73,35 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
|
|||
{error && <p className="px-5 text-body-medium text-error">{error}</p>}
|
||||
|
||||
{showToast && (
|
||||
<div className="fixed inset-x-0 bottom-33.75 z-20 mx-auto flex w-89.5 max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
|
||||
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
|
||||
<span className="flex-1 text-right text-[14px] font-bold leading-none text-emerald-500">
|
||||
محصول به سبد خرید اضافه شد
|
||||
</span>
|
||||
<div className="fixed inset-x-0 bottom-[135px] z-20 mx-auto flex w-[358px] max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
|
||||
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
|
||||
<X className="h-4 w-4 text-emerald-500" />
|
||||
</button>
|
||||
<span className="flex-1 text-right text-[14px] font-bold leading-none text-emerald-500">
|
||||
محصول به سبد خرید اضافه شد
|
||||
</span>
|
||||
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-97.5 flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={disabled || isPending}
|
||||
className="flex h-[53px] w-[245px] items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
||||
</button>
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
|
||||
<span className="text-[18px] font-bold leading-none text-foreground">
|
||||
{(product.price * quantity).toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={disabled || isPending}
|
||||
className="flex h-13.25 w-61.25 items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
|
||||
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { ChevronLeft, LogOutIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
|
||||
const ProfileExitAccount = () => {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleLogout() {
|
||||
startTransition(async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between w-full py-3 cursor-pointer"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-error/10 p-2 rounded-lg">
|
||||
<LogOutIcon className="w-4 h-4 text-error" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-error">خروج</span>
|
||||
</div>
|
||||
<ChevronLeft className="w-5 h-5 text-error" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileExitAccount;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft, User } from 'lucide-react';
|
||||
|
||||
interface ProfileMenuItemInterface {
|
||||
href: string;
|
||||
text: string;
|
||||
icon?: React.ReactNode;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const ProfileMenuItem = ({
|
||||
href,
|
||||
text,
|
||||
icon,
|
||||
isLast = false,
|
||||
}: ProfileMenuItemInterface) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`
|
||||
flex items-center justify-between w-full py-3
|
||||
${!isLast ? 'border-b border-border' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-gray-50 p-2 rounded-lg">
|
||||
{icon || <User className="w-4 h-4 text-primary-action" />}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700">{text}</span>
|
||||
</div>
|
||||
<ChevronLeft className="w-5 h-5 text-gray-500" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileMenuItem;
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
ShoppingBag,
|
||||
MapPin,
|
||||
Wallet,
|
||||
Bell,
|
||||
CircleQuestionMark,
|
||||
} from 'lucide-react';
|
||||
import ProfileMenuItem from './profile-menu-item';
|
||||
import ProfileExitAccount from './profile-exit-account';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 1,
|
||||
href: '/addresses',
|
||||
text: 'سعادتآباد',
|
||||
icon: <MapPin className="size-5 text-primary-action" />,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
href: '/orders',
|
||||
text: 'سفارشهای من',
|
||||
icon: <ShoppingBag className="size-5 text-primary-action" />,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
href: '/addresses',
|
||||
text: 'آدرسهای من',
|
||||
icon: <MapPin className="size-5 text-primary-action" />,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
href: '/profile/wallet',
|
||||
text: 'کیف پول',
|
||||
icon: <Wallet className="size-5 text-primary-action" />,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
href: '/profile/notifications',
|
||||
text: 'اعلانها',
|
||||
icon: <Bell className="size-5 text-primary-action" />,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
href: '/profile/support',
|
||||
text: 'پشتیبانی',
|
||||
icon: <CircleQuestionMark className="size-5 text-primary-action" />,
|
||||
},
|
||||
];
|
||||
|
||||
const ProfileMenu = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{menuItems.map((item, index) => (
|
||||
<ProfileMenuItem
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
text={item.text}
|
||||
icon={item.icon}
|
||||
isLast={index === menuItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
<div className="w-full h-px bg-border mt-4" />
|
||||
<ProfileExitAccount />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileMenu;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import React from 'react'
|
||||
|
||||
interface ProfilePageHeadLineInterface {
|
||||
title: String;
|
||||
}
|
||||
|
||||
const ProfilePageHeadLine = ({ title }: ProfilePageHeadLineInterface) => {
|
||||
return <h1 className="font-bold text-2xl">{title}</h1>
|
||||
}
|
||||
|
||||
export default ProfilePageHeadLine
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
'use client';
|
||||
import { useAccount } from "@/lib/providers/account-provider";
|
||||
import { getDisplayNameHelper, getInitialsHelper } from "@/lib/utils/account-utils";
|
||||
|
||||
const ProfilePersonDetailCard = () => {
|
||||
const { data, error, isLoading } = useAccount();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||
<p className="text-sm text-gray-500">اطلاعات پروفایل یافت نشد</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = getDisplayNameHelper(data);
|
||||
const initials = getInitialsHelper(data);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
|
||||
<div className="flex flex-row items-center w-full">
|
||||
<div className="size-16 bg-primary-action rounded-full flex items-center justify-center text-lg font-bold text-white overflow-hidden shrink-0">
|
||||
{data.avatar ? (
|
||||
<img
|
||||
src={data.avatar}
|
||||
alt={displayName}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col mr-6 items-start">
|
||||
<p className="text-lg font-bold">{displayName}</p>
|
||||
|
||||
{data.phone_number && (
|
||||
<p className="text-sm text-gray-700 my-2 font-normal" dir="ltr">
|
||||
{data.phone_number}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="text-sm font-semibold text-primary-action cursor-pointer underline"
|
||||
onClick={() => alert('latter...')}
|
||||
>
|
||||
ویرایش پروفایل
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePersonDetailCard;
|
||||
|
|
@ -4,20 +4,11 @@ import { useEffect, useState, useTransition } from "react";
|
|||
import Link from "next/link";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import {
|
||||
addToCart,
|
||||
updateCartItemQuantity,
|
||||
removeCartItem,
|
||||
} from "@/lib/api/cart";
|
||||
import { addToCart, updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
|
||||
import type { CartItem, ProductListItem } from "@/types/api";
|
||||
|
||||
export function ProductGridCard({
|
||||
product,
|
||||
cartItem,
|
||||
}: {
|
||||
product: ProductListItem;
|
||||
cartItem: CartItem | null;
|
||||
}) {
|
||||
/** designspec.md §2.5 — product-card, 157×254, with the two-state add/stepper control. */
|
||||
export function ProductGridCard({ product, cartItem }: { product: ProductListItem; cartItem: CartItem | null }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
|
@ -52,13 +43,12 @@ export function ProductGridCard({
|
|||
startTransition(async () => {
|
||||
if (nextQuantity <= 0) {
|
||||
const result = await removeCartItem(localItem.uuid);
|
||||
// Only clear locally once the backend actually confirms removal — otherwise
|
||||
// the card would show "add" again while the item still exists server-side.
|
||||
if (result.ok) setLocalItem(null);
|
||||
else setError(result.message);
|
||||
} else {
|
||||
const result = await updateCartItemQuantity(
|
||||
localItem.uuid,
|
||||
nextQuantity,
|
||||
);
|
||||
const result = await updateCartItemQuantity(localItem.uuid, nextQuantity);
|
||||
if (result.ok) setLocalItem(result.data);
|
||||
else setError(result.message);
|
||||
}
|
||||
|
|
@ -67,83 +57,67 @@ export function ProductGridCard({
|
|||
}
|
||||
|
||||
return (
|
||||
<article className="relative flex h-63.5 w-39.25 flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
||||
<article className="relative flex h-[254px] w-[157px] flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
|
||||
{error && (
|
||||
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/products/${product.uuid}`} className="block shrink-0">
|
||||
<div className="relative h-32.5 w-full overflow-hidden rounded-t-xl bg-muted">
|
||||
<div className="relative h-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
|
||||
{product.image_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-t from-black/40 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<Link href={`/products/${product.uuid}`}>
|
||||
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">
|
||||
{product.name}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="text-right text-body-medium font-extrabold text-primary-action">
|
||||
{product.price.toLocaleString("fa-IR")} تومان
|
||||
</p>
|
||||
{disabled && (
|
||||
<span className="mt-auto text-center text-label text-gray-500">
|
||||
ناموجود
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between p-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Link href={`/products/${product.uuid}`}>
|
||||
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
|
||||
</Link>
|
||||
<p className="text-right text-body-medium font-extrabold text-primary-action">
|
||||
{product.price.toLocaleString("fa-IR")} تومان
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!disabled && (
|
||||
<div
|
||||
className={`motion-reduce:transition-none absolute bottom-3 left-3 overflow-hidden rounded-full border transition-[width,background-color,border-color] duration-300 ease-out ${
|
||||
quantity === 0
|
||||
? "h-10 w-10 border-transparent bg-primary-action"
|
||||
: "h-10 w-28 border-gray-300 bg-white"
|
||||
}`}
|
||||
dir="ltr"
|
||||
>
|
||||
<div className="relative flex h-full w-full items-center justify-between px-1">
|
||||
{disabled ? (
|
||||
<span className="text-center text-label text-gray-500">ناموجود</span>
|
||||
) : quantity === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={isPending}
|
||||
aria-label="افزودن به سبد"
|
||||
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
quantity === 0 ? handleAdd : () => handleChange(quantity + 1)
|
||||
}
|
||||
onClick={() => handleChange(quantity + 1)}
|
||||
disabled={isPending}
|
||||
aria-label={quantity === 0 ? "افزودن به سبد" : "افزایش تعداد"}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-primary-action p-0 text-white disabled:opacity-40"
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-action text-white disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{quantity > 0 && (
|
||||
<>
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-center text-[18px] font-bold leading-none text-foreground">
|
||||
{quantity.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChange(quantity - 1)}
|
||||
disabled={isPending}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-blue-bg p-0 text-primary-action disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChange(quantity - 1)}
|
||||
disabled={isPending}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,32 @@
|
|||
import Link from "next/link";
|
||||
import { ShoppingBag } from "lucide-react";
|
||||
|
||||
/** designspec.md §2.6 — only rendered when this store's cart group actually has items. */
|
||||
export function StickyCartBar({ itemCount, total }: { itemCount: number; total: number }) {
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-97.5 flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
|
||||
order — under this app's inherited dir="rtl", the first child renders at the
|
||||
right edge and the last at the left. */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Link
|
||||
href="/cart"
|
||||
className="flex h-[50px] items-center gap-2 rounded-full bg-primary-action px-6 text-white shadow-[0_6px_16px_-6px_rgba(0,0,0,0.078),0_2px_8px_-2px_rgba(0,0,0,0.071)]"
|
||||
>
|
||||
<span className="relative flex h-[18px] w-[18px] items-center justify-center">
|
||||
<ShoppingBag className="h-[18px] w-[18px]" />
|
||||
<span className="absolute -top-1.5 -left-1.5 flex h-[14px] min-w-[14px] items-center justify-center rounded-full bg-white text-[10px] font-bold text-primary-action">
|
||||
{itemCount.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-body-large font-bold">مشاهده سبد</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
|
||||
<span className="text-[18px] font-bold leading-none text-foreground">
|
||||
{total.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/cart"
|
||||
className="flex h-12.5 items-center gap-2 rounded-xl bg-primary-action py-1 px-5 text-white shadow-[0_6px_16px_-6px_rgba(0,0,0,0.078),0_2px_8px_-2px_rgba(0,0,0,0.071)]"
|
||||
>
|
||||
<span className="text-body-large font-bold">مشاهده سبد</span>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/20 text-[13px] font-bold text-white">
|
||||
{itemCount.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
|
||||
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import type { StoreDetail } from "@/types/api";
|
|||
function todaysWorkingHours(store: StoreDetail) {
|
||||
const jsDay = new Date().getDay();
|
||||
const iranianWeekday = (jsDay + 1) % 7;
|
||||
return (
|
||||
store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null
|
||||
);
|
||||
return store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
|
|
@ -22,52 +20,35 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
|
|||
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${store.name} ${store.address}`)}`;
|
||||
|
||||
return (
|
||||
<div className="w-[90%] relative z-10 -mt-24 rounded-3xl bg-white px-4 pt-5 pb-4 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.102)]">
|
||||
<h1 className="text-title-large font-extrabold leading-none text-primary-action">
|
||||
{store.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-[14px] leading-none text-gray-700">
|
||||
{store.category.name}
|
||||
</p>
|
||||
<div className="relative z-10 -mt-6 rounded-t-3xl bg-white px-4 pt-5 pb-4">
|
||||
<h1 className="text-title-large font-extrabold leading-none text-primary-action">{store.name}</h1>
|
||||
<p className="mt-1 text-[14px] leading-none text-gray-700">{store.category.name}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{today && (
|
||||
<span className="rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
|
||||
{today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] leading-none text-gray-600">|</span>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1.5 text-[12px] font-semibold leading-none ${
|
||||
store.is_open
|
||||
? "border-emerald-border bg-emerald-bg text-emerald-text-strong"
|
||||
: "border-gray-300 bg-gray-100 text-gray-600"
|
||||
store.is_open ? "border-emerald-border bg-emerald-bg text-emerald-text-strong" : "border-gray-300 bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`}
|
||||
/>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`} />
|
||||
{store.is_open ? "فعال" : "بسته"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[13px] leading-none text-gray-600">|</span>
|
||||
{today && (
|
||||
<span className="h-6 flex items-center rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
|
||||
{today.is_closed
|
||||
? "تعطیل"
|
||||
: `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2 p-2">
|
||||
<div className="mt-4 flex items-center gap-2 rounded-[10px] border border-teal-border p-2">
|
||||
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
||||
<MapPin className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">
|
||||
{store.address}
|
||||
</span>
|
||||
<a
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 text-[13px] font-medium text-teal"
|
||||
>
|
||||
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">{store.address}</span>
|
||||
<a href={mapsUrl} target="_blank" rel="noreferrer" className="shrink-0 text-[13px] font-medium text-teal">
|
||||
مسیریابی
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -75,7 +56,7 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
|
|||
{store.phone_number && (
|
||||
<a
|
||||
href={`tel:${store.phone_number}`}
|
||||
className="mt-2 flex items-center gap-2 p-2"
|
||||
className="mt-2 flex items-center gap-2 rounded-[10px] border border-teal-border p-2"
|
||||
>
|
||||
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
|
||||
<Phone className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -8,14 +8,10 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Tailwind's preflight doesn't zero out <button>'s default border/padding (only
|
||||
// font/color/background/border-radius/opacity) — every variant that doesn't set
|
||||
// its own border must reset it explicitly, or the browser's default UA button
|
||||
// border/bevel survives underneath and throws off icon/label centering.
|
||||
primary: "border-0 bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "border-0 bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border border-border bg-transparent hover:bg-muted",
|
||||
ghost: "border-0 bg-transparent hover:bg-muted",
|
||||
ghost: "bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default: "h-12 px-4 text-body-large",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export function HeroBackButton({ href }: { href: string }) {
|
|||
<Link
|
||||
href={href}
|
||||
aria-label="بازگشت"
|
||||
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full bg-black/35 backdrop-blur-lg"
|
||||
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-md"
|
||||
>
|
||||
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import React from 'react';
|
||||
import TransactionItem from './transaction-item';
|
||||
|
||||
const transactions = [
|
||||
{ id: 1, amount: 250000, description: 'واریز از کارت', type: 'deposit' },
|
||||
{ id: 2, amount: 45000, description: 'پرداخت فاکتور', type: 'withdraw' },
|
||||
{ id: 3, amount: 120000, description: 'واریز نقدی', type: 'deposit' },
|
||||
{ id: 4, amount: 780000, description: 'حقوق ماهیانه', type: 'deposit' },
|
||||
{ id: 5, amount: 320000, description: 'خرید از فروشگاه', type: 'withdraw' },
|
||||
{ id: 6, amount: 560000, description: 'واریز چک', type: 'deposit' },
|
||||
{ id: 7, amount: 95000, description: 'شارژ اینترنت', type: 'withdraw' },
|
||||
{ id: 8, amount: 1500000, description: 'پرداخت وام', type: 'withdraw' },
|
||||
{ id: 9, amount: 430000, description: 'واریز از همراه', type: 'deposit' },
|
||||
{ id: 10, amount: 210000, description: 'خرید آنلاین', type: 'withdraw' },
|
||||
{ id: 11, amount: 670000, description: 'پاداش عملکرد', type: 'deposit' },
|
||||
{ id: 12, amount: 185000, description: 'پرداخت قبوض', type: 'withdraw' },
|
||||
{ id: 13, amount: 920000, description: 'فروش کالا', type: 'deposit' },
|
||||
{ id: 14, amount: 380000, description: 'انتقال به دوست', type: 'withdraw' },
|
||||
{ id: 15, amount: 110000, description: 'بازگشت وجه', type: 'deposit' },
|
||||
] as const;
|
||||
|
||||
const TransactionHistory = () => {
|
||||
return (
|
||||
<div className="w-full my-8">
|
||||
{transactions.map((item, index) => (
|
||||
<TransactionItem
|
||||
key={item.id}
|
||||
amount={item.amount}
|
||||
description={item.description}
|
||||
type={item.type}
|
||||
isLast={index === transactions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionHistory;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import React from 'react';
|
||||
import { ArrowDownIcon, ArrowUp } from 'lucide-react';
|
||||
import { formatPriceWithToman } from '@/lib/utils/priceUtils';
|
||||
|
||||
interface TransactionItemProps {
|
||||
amount: number;
|
||||
description: string;
|
||||
type: 'deposit' | 'withdraw';
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const TransactionItem = ({
|
||||
amount,
|
||||
description,
|
||||
type,
|
||||
isLast = false,
|
||||
}: TransactionItemProps) => {
|
||||
const isDeposit = type === 'deposit';
|
||||
const Icon = isDeposit ? ArrowDownIcon : ArrowUp;
|
||||
|
||||
const amountColor = isDeposit ? 'text-success' : 'text-error';
|
||||
const bgColor = isDeposit ? 'bg-success/10' : 'bg-error/10';
|
||||
const iconColor = isDeposit ? 'text-success' : 'text-error';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between w-full py-3
|
||||
${!isLast ? 'border-b border-border' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={`p-2 rounded-full ${bgColor}`}>
|
||||
<Icon className={`w-5 h-5 rotate-45 ${iconColor}`} />
|
||||
</div>
|
||||
<span className="text-sm font-normal flex mr-4">{description}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`text-sm font-medium ${amountColor}`}>
|
||||
{isDeposit ? '+' : '-'} {formatPriceWithToman(amount)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">۱۴۰۳/۰۸/۲۴</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionItem;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { formatPriceWithToman } from '@/lib/utils/priceUtils';
|
||||
import { LogOutIcon, PlusIcon } from 'lucide-react';
|
||||
|
||||
const WalletBlancCard = () => {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
w-full min-h-12 rounded-2xl my-6 py-6 px-6
|
||||
flex flex-col justify-start
|
||||
bg-gradient-to-l from-primary to-primary-action
|
||||
shadow-[0_12px_24px] shadow-primary/25
|
||||
border border-primary/25
|
||||
"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-gray-300">
|
||||
موجودی کیف پول
|
||||
</h4>
|
||||
<h1 className="text-2xl font-bold my-4 text-primary-foreground">
|
||||
{formatPriceWithToman(35000000)}
|
||||
</h1>
|
||||
|
||||
<div className="flex w-full gap-4">
|
||||
<button
|
||||
className="
|
||||
w-full bg-white h-12 rounded-xl cursor-pointer
|
||||
flex items-center justify-center gap-2
|
||||
"
|
||||
onClick={() => alert('latter...')}
|
||||
>
|
||||
<PlusIcon className="text-primary-action size-4" />
|
||||
<span className="text-primary-action">شارژ کیف پول</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="
|
||||
w-full h-12 rounded-xl cursor-pointer
|
||||
flex items-center justify-center gap-2
|
||||
border border-primary-foreground
|
||||
"
|
||||
onClick={() => alert('latter...')}
|
||||
>
|
||||
<LogOutIcon className="text-primary-foreground size-4 -rotate-90" />
|
||||
<span className="text-primary-foreground">برداشت</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletBlancCard;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { gooyalFetch } from "./gooyal-accounts";
|
||||
import type { Account } from "@/types/api";
|
||||
|
||||
export async function getAccount(): Promise<Account> {
|
||||
return gooyalFetch<Account>("/users/api/account/");
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { winofyFetch } from "./winofy";
|
||||
import { callApi, type ActionResult } from "./result";
|
||||
import type { DeliveryType, OrderGroup, PaymentMethod } from "@/types/api";
|
||||
|
|
@ -11,21 +10,8 @@ export interface CheckoutInput {
|
|||
scheduled_at: string | null;
|
||||
payment_method: PaymentMethod;
|
||||
notes: string;
|
||||
/**
|
||||
* Which store-cart-groups to check out, by store uuid. Required (not optional) —
|
||||
* every caller must be explicit about scope now; there's no more implicit
|
||||
* "everything in the cart" default. This is what fixes the bug where paying for
|
||||
* one store's order submitted every store's order: previously nothing on this
|
||||
* request identified which store(s) were intended, so the backend checked out
|
||||
* the whole cart every time.
|
||||
*/
|
||||
store_uuids: string[];
|
||||
}
|
||||
|
||||
export async function submitCheckout(input: CheckoutInput): Promise<ActionResult<OrderGroup>> {
|
||||
const result = await callApi(() => winofyFetch<OrderGroup>("/v1/checkout/", { method: "POST", body: input }));
|
||||
// Only the scoped store(s)' items are removed server-side now — the cart page
|
||||
// (and the sticky-cart-bar badge) needs a fresh fetch to stop showing them.
|
||||
if (result.ok) revalidatePath("/cart");
|
||||
return result;
|
||||
return callApi(() => winofyFetch<OrderGroup>("/v1/checkout/", { method: "POST", body: input }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,19 +46,17 @@ function indent(text: string): string {
|
|||
|
||||
/**
|
||||
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
|
||||
* tab for every server-side call this app makes (Winofy API, Gooyal accounts),
|
||||
* since those run on the Next.js server and the browser never sees them. Shared
|
||||
* across every backend this app talks to, labeled by `service` so interleaved
|
||||
* calls stay easy to tell apart. Never runs in production.
|
||||
* tab for server-side calls, since they run on the Next.js server and the browser
|
||||
* never sees them (no CORS on the Winofy API). Never runs in production.
|
||||
*/
|
||||
export function logApiRequest(service: string, method: string, url: string, body: unknown) {
|
||||
export function logWinofyRequest(method: string, url: string, body: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`\n${CYAN}→ [${service}]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
|
||||
}
|
||||
|
||||
export function logApiResponse(service: string, method: string, url: string, status: number, data: unknown) {
|
||||
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`${statusColor(status)}← [${service}] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import "server-only";
|
||||
import { getValidSession } from "@/lib/auth/session";
|
||||
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||
import { logApiRequest, logApiResponse } from "./dev-logger";
|
||||
|
||||
const BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||
|
||||
interface GooyalFetchOptions extends Omit<RequestInit, "body"> {
|
||||
auth?: boolean;
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export async function gooyalFetch<T>(path: string, options: GooyalFetchOptions = {}): Promise<T> {
|
||||
const { auth = true, params, headers, body, ...rest } = options;
|
||||
const method = rest.method ?? "GET";
|
||||
|
||||
const url = new URL(BASE_URL.replace(/\/$/, "") + path);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const finalHeaders = new Headers(headers);
|
||||
const hasBody = body !== undefined;
|
||||
if (hasBody && !finalHeaders.has("Content-Type")) {
|
||||
finalHeaders.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
const session = await getValidSession();
|
||||
if (session) {
|
||||
finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
logApiRequest("gooyal", method, url.toString(), body);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
...rest,
|
||||
headers: finalHeaders,
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
logApiResponse("gooyal", method, url.toString(), res.status, null);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
logApiResponse("gooyal", method, url.toString(), res.status, data);
|
||||
|
||||
if (!res.ok) {
|
||||
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
envelope?.status_message ?? res.statusText,
|
||||
envelope?.details ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import "server-only";
|
||||
import { getValidSession } from "@/lib/auth/session";
|
||||
import { ApiError, type WinofyErrorDetails } from "./errors";
|
||||
import { logApiRequest, logApiResponse } from "./dev-logger";
|
||||
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
|
||||
|
||||
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
|||
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
|
||||
}
|
||||
|
||||
logApiRequest("winofy", method, url.toString(), body);
|
||||
logWinofyRequest(method, url.toString(), body);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
...rest,
|
||||
|
|
@ -51,12 +51,12 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
|
|||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
logApiResponse("winofy", method, url.toString(), res.status, null);
|
||||
logWinofyResponse(method, url.toString(), res.status, null);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
logApiResponse("winofy", method, url.toString(), res.status, data);
|
||||
logWinofyResponse(method, url.toString(), res.status, data);
|
||||
|
||||
if (!res.ok) {
|
||||
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||
|
|
|
|||
|
|
@ -95,4 +95,4 @@ export async function exchangeOtpForToken(phoneNumber: string, otp: string) {
|
|||
throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description);
|
||||
}
|
||||
return data as GooyalTokenResponse;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import "server-only";
|
||||
import { logApiRequest, logApiResponse } from "@/lib/api/dev-logger";
|
||||
|
||||
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
|
||||
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
|
||||
|
|
@ -35,16 +34,11 @@ export interface GooyalTokenResponse {
|
|||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
|
||||
const url = `${ACCOUNTS_BASE_URL}/oauth2/token/`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
// Never print real tokens to the terminal — log the request shape, not the secret values.
|
||||
logApiRequest("gooyal", "POST", url, { grant_type: "refresh_token", refresh_token: "<redacted>" });
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(),
|
||||
|
|
@ -54,12 +48,6 @@ export async function refreshAccessToken(refreshToken: string): Promise<GooyalTo
|
|||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
logApiResponse("gooyal", "POST", url, res.status, {
|
||||
...data,
|
||||
access_token: data.access_token ? "<redacted>" : undefined,
|
||||
refresh_token: data.refresh_token ? "<redacted>" : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { Account } from "@/types/api";
|
||||
|
||||
type AccountContextValue = {
|
||||
data: Account | null;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
|
||||
|
||||
export function AccountProvider({
|
||||
children,
|
||||
account,
|
||||
error = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
account: Account | null;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<AccountContext.Provider
|
||||
value={{
|
||||
data: account,
|
||||
error,
|
||||
isLoading: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAccount() {
|
||||
const ctx = useContext(AccountContext);
|
||||
if (ctx === undefined) {
|
||||
throw new Error("useAccount must be used within AccountProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useAccount } from '@/lib/providers/account-provider';
|
||||
import React from 'react';
|
||||
|
||||
export function getInitialsHelper(account: {
|
||||
name?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
username?: string | null;
|
||||
}): string {
|
||||
if (account.name?.trim()) {
|
||||
const parts = account.name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
if (account.first_name || account.last_name) {
|
||||
const first = account.first_name?.[0] ?? '';
|
||||
const last = account.last_name?.[0] ?? '';
|
||||
return (first + last).toUpperCase() || '؟';
|
||||
}
|
||||
|
||||
if (account.username) {
|
||||
return account.username.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return '؟';
|
||||
}
|
||||
|
||||
export function getDisplayNameHelper(account: {
|
||||
name?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
username?: string | null;
|
||||
}): string {
|
||||
if (account.name?.trim()) return account.name.trim();
|
||||
|
||||
const fullName = [account.first_name, account.last_name]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
if (fullName) return fullName;
|
||||
|
||||
return account.username || 'کاربر';
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Converts Rial to Toman (divides by 10)
|
||||
*/
|
||||
export const convertRialToToman = (rialAmount: number): number => {
|
||||
return Math.round(rialAmount / 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats number with comma separators (e.g., 3,000,000)
|
||||
*/
|
||||
export const formatNumberWithCommas = (amount: number): string => {
|
||||
return amount.toLocaleString('fa-IR'); // for Persian digits
|
||||
// Or for English digits: return amount.toLocaleString('en-US');
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function: receives amount in Rial or Toman, returns formatted with comma separators and "Toman" label
|
||||
*
|
||||
* @param amount - input amount (in Rial or Toman)
|
||||
* @param inputIsRial - whether the input amount is in Rial (default: true)
|
||||
* @returns string - formatted amount with Toman label (e.g., "۳,۰۰۰,۰۰۰ تومان")
|
||||
*/
|
||||
export const formatPriceWithToman = (
|
||||
amount: number,
|
||||
inputIsRial: boolean = true
|
||||
): string => {
|
||||
// If amount is in Rial, convert to Toman first
|
||||
const tomanAmount = inputIsRial ? convertRialToToman(amount) : amount;
|
||||
|
||||
// Add comma separators
|
||||
const formatted = formatNumberWithCommas(tomanAmount);
|
||||
|
||||
// Return with Toman label
|
||||
return `${formatted} تومان`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simpler version: just take an amount in Toman and format it
|
||||
*/
|
||||
export const formatTomanPrice = (tomanAmount: number): string => {
|
||||
return `${formatNumberWithCommas(tomanAmount)} تومان`;
|
||||
};
|
||||
|
|
@ -310,42 +310,3 @@ export interface SellerAnalytics {
|
|||
chart: SalesChartPoint[];
|
||||
top_products: TopProduct[];
|
||||
}
|
||||
|
||||
|
||||
// ---- Account / Profile ----------------------------------------------------
|
||||
|
||||
export type Gender = "m" | "f" | "other" | null;
|
||||
export type EducationDegree =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "diploma"
|
||||
| "bachelor"
|
||||
| "master"
|
||||
| "phd"
|
||||
| string
|
||||
| null;
|
||||
|
||||
export interface Account {
|
||||
uuid: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
phone_number: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
gender: Gender;
|
||||
education_degree: EducationDegree;
|
||||
education_field: string | null;
|
||||
birthdate: string | null; // "YYYY-MM-DD"
|
||||
iban: string | null;
|
||||
iban_verified: boolean;
|
||||
province: number | null;
|
||||
province_name: string | null;
|
||||
city: number | null;
|
||||
city_name: string | null;
|
||||
postal_code: string | null;
|
||||
address: string | null;
|
||||
date_joined: string;
|
||||
last_update: string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue