Compare commits
33 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03c511a46 | ||
|
|
5d82f33a6f | ||
| 228d9cd546 | |||
| 676ef9cfad | |||
| 7e9ab5d68f | |||
| a9268b8a94 | |||
| 4e3eb2de9f | |||
|
|
b548c59484 | ||
|
|
c2b4c4a690 | ||
|
|
6c5d02ddd0 | ||
|
|
2048e99bf2 | ||
| 464f2b34a2 | |||
| 2363fc0f88 | |||
| 1fe7c41bdc | |||
| bae58c8548 | |||
| 61b82b0fb2 | |||
|
|
ef75ed1a0c | ||
|
|
08e4a1a98a | ||
|
|
f94ee531a3 | ||
|
|
adf31dc999 | ||
|
|
64e321dc3d | ||
|
|
418a63734f | ||
| 89d2a090e1 | |||
| 59ef2707b2 | |||
| f4259dee26 | |||
| 8ff9266c55 | |||
|
|
cc928871cc | ||
| b0ce5ce2f9 | |||
| 6d917c6cfa | |||
| cbd0b4bebe | |||
| 41e86d30b3 | |||
| 2cd5a9987d | |||
| 2c6c8ad6e1 |
77 changed files with 2985 additions and 852 deletions
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/winofy-front.iml" filepath="$PROJECT_DIR$/.idea/winofy-front.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/winofy-front.iml
generated
Normal file
8
.idea/winofy-front.iml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
202
package-lock.json
generated
202
package-lock.json
generated
|
|
@ -19,15 +19,18 @@
|
|||
"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",
|
||||
|
|
@ -291,14 +294,14 @@
|
|||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -313,9 +316,9 @@
|
|||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -683,9 +686,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -702,9 +702,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -721,9 +718,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -740,9 +734,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -759,9 +750,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -778,9 +766,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -797,9 +782,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -816,9 +798,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -835,9 +814,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -860,9 +836,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -885,9 +858,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -910,9 +880,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -935,9 +902,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -960,9 +924,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -985,9 +946,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1010,9 +968,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1280,9 +1235,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1299,9 +1251,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1318,9 +1267,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1337,9 +1283,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2133,6 +2076,17 @@
|
|||
"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",
|
||||
|
|
@ -2282,9 +2236,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2302,9 +2253,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2322,9 +2270,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2342,9 +2287,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2476,6 +2418,13 @@
|
|||
"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",
|
||||
|
|
@ -2490,6 +2439,16 @@
|
|||
"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",
|
||||
|
|
@ -2921,9 +2880,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2938,9 +2894,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2955,9 +2908,6 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2972,9 +2922,6 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2989,9 +2936,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3006,9 +2950,6 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3023,9 +2964,6 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3040,9 +2978,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3057,9 +2992,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3074,9 +3006,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3116,6 +3045,18 @@
|
|||
"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",
|
||||
|
|
@ -3127,6 +3068,17 @@
|
|||
"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",
|
||||
|
|
@ -5661,6 +5613,12 @@
|
|||
"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",
|
||||
|
|
@ -5818,9 +5776,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5842,9 +5797,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5866,9 +5818,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5890,9 +5839,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -6599,6 +6545,20 @@
|
|||
"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,15 +20,18 @@
|
|||
"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",
|
||||
|
|
|
|||
3
public/server-error-icon.svg
Normal file
3
public/server-error-icon.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.0027 38.0953C11.5441 36.9277 9.41619 35.164 7.81244 32.9647C6.2087 30.7653 5.17996 28.2 4.81972 25.5018C4.45948 22.8037 4.77916 20.0582 5.74972 17.5151C6.72028 14.9719 8.31093 12.7117 10.3771 10.9398C12.4433 9.16786 14.9194 7.94048 17.5806 7.36917C20.2417 6.79787 23.0034 6.90076 25.6146 7.66849C28.2259 8.43623 30.6039 9.84446 32.5325 11.7652C34.4612 13.6859 35.8793 16.0583 36.658 18.6666H40.8344C43.521 18.6619 46.1072 19.6875 48.0607 21.5321C50.0142 23.3768 51.1863 25.9003 51.3358 28.5831C51.4852 31.2659 50.6007 33.904 48.8642 35.9543C47.1278 38.0047 44.6715 39.3112 42.0009 39.6051M30.3342 28L23.3347 39.6675H32.6674L25.6679 51.335" stroke="#EF4444" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 B |
30
src/app/(customer)/(shop)/addresses/map/page.tsx
Normal file
30
src/app/(customer)/(shop)/addresses/map/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
42
src/app/(customer)/(shop)/addresses/new/page.tsx
Normal file
42
src/app/(customer)/(shop)/addresses/new/page.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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,16 +1,24 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getAddresses } from "@/lib/api/addresses";
|
||||
import { getCities, getNeighborhoods } from "@/lib/api/locations";
|
||||
import { AddressList } from "@/components/customer/address-list";
|
||||
import { AddressSelector } from "@/components/customer/address-selector";
|
||||
|
||||
export default async function AddressesPage() {
|
||||
export default async function AddressesPage({ searchParams }: PageProps<"/addresses">) {
|
||||
await requireSession("/addresses");
|
||||
const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]);
|
||||
const [addresses, { next }] = await Promise.all([getAddresses(), searchParams]);
|
||||
|
||||
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>
|
||||
<AddressList addresses={addresses} cities={cities} neighborhoods={neighborhoods} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { CartItemRow } from "@/components/customer/cart-item-row";
|
||||
import { StoreCartCard } from "@/components/customer/store-cart-card";
|
||||
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,55 +20,69 @@ 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-2xl flex-1 flex-col gap-6 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">سبد خرید</h1>
|
||||
|
||||
{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}
|
||||
<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>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{group.items.map((item) => (
|
||||
<CartItemRow key={item.uuid} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h1 className="text-[18px] font-bold leading-none text-foreground">
|
||||
سبد خرید
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{cart.groups.map((group) => (
|
||||
<StoreCartCard key={group.store.uuid} group={group}>
|
||||
{!group.meets_minimum_order && (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<p className="mt-3 rounded-lg bg-error/10 px-3 py-2 text-xs text-error">
|
||||
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
|
||||
</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
|
||||
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>
|
||||
|
||||
<span className="text-[16px] font-bold leading-none text-foreground">
|
||||
{cart.grand_total.toLocaleString("fa-IR")} تومان
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
47
src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx
Normal file
47
src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
31
src/app/(customer)/(shop)/checkout/[store]/page.tsx
Normal file
31
src/app/(customer)/(shop)/checkout/[store]/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
43
src/app/(customer)/(shop)/orders/(routes)/(landing)/page.tsx
Normal file
43
src/app/(customer)/(shop)/orders/(routes)/(landing)/page.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useOrders } from "@/lib/providers/orders-provider";
|
||||
import OrderItem from "@/components/orders/order-item";
|
||||
import OrderEmpty from "@/components/orders/order-empty";
|
||||
import OrderServerError from "@/components/orders/order-server-error";
|
||||
import OrdersSkeleton from "@/components/orders/orders-skeleton";
|
||||
|
||||
export default function OrdersPage() {
|
||||
const { data, error, isLoading } = useOrders();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="p-4">
|
||||
<OrdersSkeleton />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="p-4">
|
||||
<OrderServerError />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<section className="p-4">
|
||||
<OrderEmpty />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="p-4 space-y-3">
|
||||
{data.map((group) => (
|
||||
<OrderItem key={group.uuid} orderGroup={group} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
29
src/app/(customer)/(shop)/orders/(routes)/[orderId]/page.tsx
Normal file
29
src/app/(customer)/(shop)/orders/(routes)/[orderId]/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroup } from "@/lib/api/orders";
|
||||
import { OrderDetailProvider } from "@/lib/providers/order-detail-provider";
|
||||
import OrderDetailContent from "@/components/orders/order-detail-content";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ orderId: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: Props) {
|
||||
await requireSession("/login");
|
||||
|
||||
const { orderId } = await params;
|
||||
|
||||
let orderGroup = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
orderGroup = await getOrderGroup(orderId);
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? "خطا در دریافت اطلاعات سفارش";
|
||||
}
|
||||
|
||||
return (
|
||||
<OrderDetailProvider order={orderGroup} error={error}>
|
||||
<OrderDetailContent />
|
||||
</OrderDetailProvider>
|
||||
);
|
||||
}
|
||||
30
src/app/(customer)/(shop)/orders/layout.tsx
Normal file
30
src/app/(customer)/(shop)/orders/layout.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import OrderHeader from '@/components/orders/order-header';
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroups } from "@/lib/api/orders";
|
||||
import { OrdersProvider } from "@/lib/providers/orders-provider";
|
||||
|
||||
interface OrderLayoutInterface {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function OrderLayout({ children }: OrderLayoutInterface) {
|
||||
await requireSession("/login");
|
||||
|
||||
let orderGroups: Awaited<ReturnType<typeof getOrderGroups>> | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
orderGroups = await getOrderGroups();
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? "خطا در دریافت سفارشها";
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl w-full mx-auto p-0">
|
||||
<OrdersProvider orderGroups={orderGroups} error={error}>
|
||||
<OrderHeader />
|
||||
{children}
|
||||
</OrdersProvider>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrderGroups } from "@/lib/api/orders";
|
||||
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
|
||||
|
||||
export default async function OrdersPage() {
|
||||
await requireSession("/orders");
|
||||
const orderGroups = await getOrderGroups();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-4 px-4 py-6">
|
||||
<h1 className="text-lg font-bold">سفارشهای من</h1>
|
||||
|
||||
{orderGroups.length === 0 && <p className="text-sm text-muted-foreground">هنوز سفارشی ثبت نکردهاید.</p>}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{orderGroups.map((group) => (
|
||||
<Link
|
||||
key={group.uuid}
|
||||
href={`/order-groups/${group.uuid}`}
|
||||
className="flex flex-col gap-2 rounded-2xl border border-border p-4"
|
||||
>
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{new Date(group.created_at).toLocaleDateString("fa-IR")}</span>
|
||||
<span>{PAYMENT_STATUS_LABELS_FA[group.payment_status]}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-sm">
|
||||
{group.orders.map((order) => (
|
||||
<span key={order.uuid} className="rounded-full bg-muted px-2 py-0.5">
|
||||
{order.store.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>مجموع</span>
|
||||
<span>{group.total_amount.toLocaleString("fa-IR")} تومان</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</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-[140px]">
|
||||
<div className="relative h-[320px] w-full shrink-0 overflow-hidden bg-muted">
|
||||
<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">
|
||||
{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-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
<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-semibold leading-none text-primary-action">
|
||||
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-medium leading-none text-gray-600">
|
||||
{product.store.name}
|
||||
</Link>
|
||||
<p className="text-headline-medium font-extrabold leading-none text-primary-action">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
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>
|
||||
}
|
||||
16
src/app/(customer)/(shop)/profile/(routes)/wallet/page.tsx
Normal file
16
src/app/(customer)/(shop)/profile/(routes)/wallet/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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
|
||||
28
src/app/(customer)/(shop)/profile/layout.tsx
Normal file
28
src/app/(customer)/(shop)/profile/layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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,45 +25,70 @@ 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-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
|
||||
<div className="relative h-64.25 w-full shrink-0 overflow-hidden rounded-b-2xl 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="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 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>
|
||||
</section>
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="pb-6">
|
||||
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
|
||||
</div>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
|
||||
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-30">
|
||||
{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`}>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||
<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>
|
||||
<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"
|
||||
? "bg-primary-action text-white hover:bg-primary-action/90"
|
||||
? "border-0 bg-primary-action text-white hover:bg-primary-action/90"
|
||||
: "border border-primary-action bg-white text-primary-action hover:bg-primary-action/5",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
240
src/components/customer/address-details-form.tsx
Normal file
240
src/components/customer/address-details-form.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
106
src/components/customer/address-selector.tsx
Normal file
106
src/components/customer/address-selector.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"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,59 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Minus, Plus, Trash2 } from "lucide-react";
|
||||
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 () => {
|
||||
if (nextQuantity <= 0) {
|
||||
await removeCartItem(item.uuid);
|
||||
} else {
|
||||
await updateCartItemQuantity(item.uuid, nextQuantity);
|
||||
}
|
||||
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)}`);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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-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 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-2 rounded-full border border-border px-2 py-1">
|
||||
</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)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
−
|
||||
<Minus className="h-3.5 w-3.5 text-foreground" />
|
||||
</button>
|
||||
<span className="w-5 text-center text-sm">{item.quantity}</span>
|
||||
<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)}
|
||||
className="flex h-6 w-6 items-center justify-center text-sm"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
+
|
||||
<Plus className="h-3.5 w-3.5 text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => changeQuantity(0)}
|
||||
aria-label="حذف از سبد"
|
||||
className="flex h-8 w-8 items-center justify-center disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-4.5 w-4.5 text-error" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-end text-xs text-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
73
src/components/customer/checkout-address.tsx
Normal file
73
src/components/customer/checkout-address.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
113
src/components/customer/checkout-fulfillment.tsx
Normal file
113
src/components/customer/checkout-fulfillment.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
120
src/components/customer/checkout-payment.tsx
Normal file
120
src/components/customer/checkout-payment.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
48
src/components/customer/clear-cart-button.tsx
Normal file
48
src/components/customer/clear-cart-button.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
38
src/components/customer/store-cart-card.tsx
Normal file
38
src/components/customer/store-cart-card.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
98
src/components/location/address-map-leaflet.tsx
Normal file
98
src/components/location/address-map-leaflet.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
73
src/components/location/address-map-picker.tsx
Normal file
73
src/components/location/address-map-picker.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
63
src/components/orders/order-detail-content.tsx
Normal file
63
src/components/orders/order-detail-content.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useOrderDetail } from "@/lib/providers/order-detail-provider";
|
||||
import OrderStepperStatus from "./order-stepper-status";
|
||||
import OrderTimeAddressInfoAlert from "./order-time-address-info-alert";
|
||||
import OrderItems from "./order-items";
|
||||
import OrderPaymentSticky from "./order-payment-sticky";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
placed: "ثبت شد",
|
||||
preparing: "در حال آمادهسازی",
|
||||
ready_to_ship: "آماده ارسال",
|
||||
handed_to_courier: "ارسال شد",
|
||||
delivered: "تحویل داده شد",
|
||||
cancelled: "لغو شد",
|
||||
};
|
||||
|
||||
export default function OrderDetailContent() {
|
||||
const { data: orderGroup, error, isLoading } = useOrderDetail();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center text-gray-500">در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
if (error || !orderGroup) {
|
||||
return (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
{error ?? "سفارش یافت نشد"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// وضعیت اصلی رو از اولین سفارش میگیریم (یا میتونی منطق پیچیدهتری بذاری)
|
||||
const mainStatus = orderGroup.orders[0]?.status ?? "placed";
|
||||
|
||||
const statusToStep: Record<string, number> = {
|
||||
placed: 0,
|
||||
preparing: 2,
|
||||
ready_to_ship: 3,
|
||||
handed_to_courier: 3,
|
||||
delivered: 4,
|
||||
cancelled: -1,
|
||||
};
|
||||
|
||||
const currentStep = statusToStep[mainStatus] ?? 0;
|
||||
const statusLabel = STATUS_LABELS[mainStatus] ?? mainStatus;
|
||||
|
||||
return (
|
||||
<section className="relative pb-32">
|
||||
<h2 className="w-full p-3 px-4 bg-primary-action text-primary-foreground text-md font-medium">
|
||||
{statusLabel}
|
||||
</h2>
|
||||
|
||||
{mainStatus !== "cancelled" && (
|
||||
<OrderStepperStatus currentStep={currentStep} />
|
||||
)}
|
||||
|
||||
<OrderTimeAddressInfoAlert order={orderGroup} />
|
||||
<OrderItems order={orderGroup} />
|
||||
<OrderPaymentSticky order={orderGroup} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
19
src/components/orders/order-empty.tsx
Normal file
19
src/components/orders/order-empty.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { ShoppingBagIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const OrderEmpty = () => {
|
||||
return (
|
||||
<div className='h-96 flex items-center justify-center p-5'>
|
||||
<div className='max-w-xl w-full p-5 flex items-center justify-center flex-col gap-3'>
|
||||
<div className='size-30 mb-2 bg-gray-100 flex items-center justify-center rounded-full'>
|
||||
<ShoppingBagIcon className='size-12 text-gray-600' />
|
||||
</div>
|
||||
<h1 className='text-lg font-bold'>هنوز سفارشی ثبت نکردهاید</h1>
|
||||
<p className='text-sm text-center text-gray-600'>لیست سفارشهای شما در حال حاضر خالی است. برای خرید از فروشگاههای اطراف روی دکمه زیر کلیک کنید.</p>
|
||||
<Link href='/' className='h-12 mt-4 rounded-full flex items-center justify-center w-full bg-primary-action text-white cursor-pointer'>شروع خرید</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderEmpty
|
||||
44
src/components/orders/order-header.tsx
Normal file
44
src/components/orders/order-header.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import { ArrowRight, ShoppingBagIcon } from 'lucide-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
const OrderHeader = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isExactOrders = pathname === '/orders';
|
||||
|
||||
const onBackHandler = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const goToCartPage = () => {
|
||||
router.push('/cart');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between w-full mb-3 px-4 pt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowRight className="size-5 cursor-pointer" onClick={onBackHandler} />
|
||||
<h2 className="text-lg font-bold">سفارشها</h2>
|
||||
</div>
|
||||
|
||||
{isExactOrders && (
|
||||
<div
|
||||
className="relative cursor-pointer inline-block"
|
||||
onClick={goToCartPage}
|
||||
>
|
||||
<div className="size-10 rounded-full flex items-center justify-center">
|
||||
<ShoppingBagIcon className="size-6" />
|
||||
</div>
|
||||
|
||||
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-4 h-4 px-1 bg-primary-action text-primary-foreground text-[10px] font-bold rounded-full">
|
||||
90
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderHeader;
|
||||
127
src/components/orders/order-item.tsx
Normal file
127
src/components/orders/order-item.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status"; // اگه داری
|
||||
|
||||
interface OrderItemProps {
|
||||
orderGroup: OrderGroup;
|
||||
}
|
||||
|
||||
const OrderItem = ({ orderGroup }: OrderItemProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const goToOrderDetail = () => {
|
||||
router.push(`/orders/${orderGroup.uuid}`); // یا مسیر واقعی جزئیات
|
||||
};
|
||||
|
||||
// وضعیت کلی (از اولین سفارش یا payment_status)
|
||||
const mainOrder = orderGroup.orders[0];
|
||||
const status = mainOrder?.status ?? "placed";
|
||||
|
||||
// مپ وضعیت به فارسی و کلاس
|
||||
const statusMap: Record<string, { label: string; className: string }> = {
|
||||
placed: { label: "ثبت شده", className: "bg-[#FFF4E5] text-[#E67E22]" },
|
||||
preparing: { label: "در حال آمادهسازی", className: "bg-[#E5F3FF] text-[#007BFF]" },
|
||||
ready_to_ship: { label: "آماده ارسال", className: "bg-[#EBE5FF] text-[#734DBF]" },
|
||||
handed_to_courier: { label: "ارسال شد", className: "bg-teal-bg text-primary-action" },
|
||||
delivered: { label: "تحویل داده شد", className: "bg-emerald-bg text-success" },
|
||||
cancelled: { label: "لغو شد", className: "bg-[#FCE8E8] text-error" },
|
||||
};
|
||||
|
||||
const currentStatus = statusMap[status] ?? { label: status, className: "bg-gray-100 text-gray-700" };
|
||||
|
||||
// نام فروشگاهها
|
||||
const storeNames = orderGroup.orders.map(o => o.store.name).join(" + ");
|
||||
|
||||
// تعداد کل کالاها
|
||||
const totalItems = orderGroup.orders.reduce((sum, order) => {
|
||||
return sum + order.items.reduce((s, item) => s + item.quantity, 0);
|
||||
}, 0);
|
||||
|
||||
// تصاویر محصولات (حداکثر ۵ تا)
|
||||
const images = orderGroup.orders
|
||||
.flatMap(order => order.items)
|
||||
.slice(0, 5)
|
||||
.map((item, index) => ({
|
||||
id: index,
|
||||
src: "", // اگر تصویر محصول داری اینجا بذار، فعلاً خالی
|
||||
alt: item.product_name_snapshot,
|
||||
}));
|
||||
|
||||
// تاریخ و ساعت
|
||||
const createdDate = new Date(orderGroup.created_at);
|
||||
const timeText = createdDate.toLocaleDateString("fa-IR", {
|
||||
weekday: "long",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-2xl min-h-32 border border-gray-300 bg-background p-4 mb-2">
|
||||
{/* هدر */}
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-full bg-gradient-to-br from-primary-action to-primary flex items-center justify-center text-primary-foreground font-bold text-lg overflow-hidden">
|
||||
{mainOrder?.store.logo_url ? (
|
||||
<img src={mainOrder.store.logo_url} alt={mainOrder.store.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
mainOrder?.store.name?.charAt(0) ?? "س"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<h2 className="text-md font-bold text-gray-800 line-clamp-1">{storeNames}</h2>
|
||||
<p className="text-sm text-gray-600 line-clamp-1">
|
||||
{orderGroup.orders.length > 1
|
||||
? `${orderGroup.orders.length} فروشگاه`
|
||||
: mainOrder?.items[0]?.product_name_snapshot ?? "سفارش"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`flex items-center justify-center px-3 py-1 rounded-full text-xs font-bold ${currentStatus.className}`}>
|
||||
{currentStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-gray-200 my-4" />
|
||||
|
||||
{/* پایین کارت */}
|
||||
<div className="grid grid-cols-3 gap-3 w-full">
|
||||
<div className="col-span-2 flex items-center gap-1 overflow-x-auto pb-2">
|
||||
{images.length > 0 ? (
|
||||
images.map((img) => (
|
||||
<div key={img.id} className="size-10 flex-shrink-0 rounded-lg bg-gray-200 overflow-hidden">
|
||||
{img.src ? (
|
||||
<img src={img.src} alt={img.alt} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-gray-400">
|
||||
{img.alt.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">بدون تصویر</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex flex-col items-end gap-1">
|
||||
<div className="text-xs text-gray-600">{timeText}</div>
|
||||
<div className="text-primary-action text-sm font-bold text-left">
|
||||
{totalItems} کالا • {orderGroup.total_amount.toLocaleString("fa-IR")} تومان
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full mt-6 rounded-xl h-12 bg-primary-action text-primary-foreground cursor-pointer hover:bg-primary-action/90 transition-colors font-medium"
|
||||
onClick={goToOrderDetail}
|
||||
>
|
||||
مشاهده وضعیت
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderItem;
|
||||
45
src/components/orders/order-items.tsx
Normal file
45
src/components/orders/order-items.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
import {formatTomanPrice} from "@/lib/utils/priceUtils";
|
||||
|
||||
interface Props {
|
||||
order: OrderGroup;
|
||||
}
|
||||
|
||||
const OrderItems = ({ order }: Props) => {
|
||||
// همه آیتمهای همه فروشگاهها رو جمع میکنیم
|
||||
const allItems = order.orders.flatMap((o) =>
|
||||
o.items.map((item) => ({
|
||||
...item,
|
||||
storeName: o.store.name,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 mx-8">
|
||||
<h1 className="text-lg font-bold mb-3">اقلام سفارش</h1>
|
||||
|
||||
{allItems.map((item) => (
|
||||
<div key={item.uuid} className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="font-semibold text-sm">{item.product_name_snapshot}</span>
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{item.quantity.toLocaleString("fa-IR")} عدد
|
||||
</span>
|
||||
</div>
|
||||
{order.orders.length > 1 && (
|
||||
<span className="text-xs text-gray-500">{item.storeName}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold">
|
||||
{formatTomanPrice(item.line_total)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderItems;
|
||||
48
src/components/orders/order-payment-sticky.tsx
Normal file
48
src/components/orders/order-payment-sticky.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
import {formatTomanPrice} from "@/lib/utils/priceUtils";
|
||||
|
||||
interface Props {
|
||||
order: OrderGroup;
|
||||
}
|
||||
|
||||
const OrderPaymentSticky = ({ order }: Props) => {
|
||||
const paymentMethodLabel: Record<string, string> = {
|
||||
wallet: "پرداخت از کیف پول",
|
||||
online: "پرداخت آنلاین",
|
||||
cash_on_delivery: "پرداخت در محل",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-gray-100 w-full">
|
||||
<div className="px-5 pt-5 pb-3 max-w-sm mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-lg font-medium text-foreground">مبلغ پرداختی</span>
|
||||
<span className="text-xs font-normal text-gray-700">
|
||||
{paymentMethodLabel[order.payment_method] ?? order.payment_method}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-medium text-foreground">
|
||||
{formatTomanPrice(order.total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* فعلاً فقط نمایش، بعداً میتونی دکمه لغو اضافه کنی */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full h-12 rounded-xl border border-error bg-background text-error text-[15px] font-medium active:scale-[0.98] transition-transform cursor-pointer"
|
||||
>
|
||||
لغو سفارش
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pb-2">
|
||||
<div className="w-[134px] h-[5px] rounded-full bg-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderPaymentSticky;
|
||||
24
src/components/orders/order-server-error.tsx
Normal file
24
src/components/orders/order-server-error.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ServerCrashIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
const OrderServerError = () => {
|
||||
return (
|
||||
<div className="h-96 flex items-center justify-center p-5">
|
||||
<div className="max-w-xl w-full p-5 flex items-center justify-center flex-col gap-3">
|
||||
<div className="size-30 mb-2 bg-[#FEF2F2] flex items-center justify-center rounded-full">
|
||||
{/* <ServerCrashIcon className="size-12 text-error" /> */}
|
||||
<img src="/server-error-icon.svg" alt="وینسو" className="size-16" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold">خطا در برقراری ارتباط</h1>
|
||||
<p className="text-sm text-center text-gray-600">
|
||||
متأسفانه در دریافت اطلاعات سفارشها مشکلی پیش آمده است. لطفاً اتصال اینترنت خود را بررسی کرده و مجدداً تلاش کنید.
|
||||
</p>
|
||||
<button className="h-12 mt-4 rounded-full w-full border border-primary-action bg-background text-primary-action font-bold cursor-pointer">
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderServerError
|
||||
100
src/components/orders/order-stepper-status.tsx
Normal file
100
src/components/orders/order-stepper-status.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
type StepStatus = 'completed' | 'current' | 'upcoming'
|
||||
|
||||
interface Step {
|
||||
label: string
|
||||
status: StepStatus
|
||||
}
|
||||
|
||||
interface OrderStepperStatusProps {
|
||||
/** ایندکس مرحله فعلی (۰-based). مراحل قبل completed، خودش current، بعد upcoming */
|
||||
currentStep?: number
|
||||
steps?: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_STEPS = [
|
||||
'ثبت شد',
|
||||
'پذیرفته شد',
|
||||
'آمادهسازی',
|
||||
'ارسال شد',
|
||||
'تحویل',
|
||||
]
|
||||
|
||||
const OrderStepperStatus = ({
|
||||
currentStep = 2,
|
||||
steps = DEFAULT_STEPS,
|
||||
}: OrderStepperStatusProps) => {
|
||||
const computedSteps: Step[] = steps.map((label, index) => {
|
||||
if (index < currentStep) return { label, status: 'completed' }
|
||||
if (index === currentStep) return { label, status: 'current' }
|
||||
return { label, status: 'upcoming' }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full px-8 py-6" dir="rtl">
|
||||
<div className="flex items-center w-full">
|
||||
{computedSteps.map((step, index) => {
|
||||
const isLast = index === computedSteps.length - 1
|
||||
const isCompleted = step.status === 'completed'
|
||||
const isCurrent = step.status === 'current'
|
||||
|
||||
const circleBg = step.status === 'upcoming' ? 'bg-gray-300' : 'bg-primary-action'
|
||||
const nextLineBg =
|
||||
index < currentStep ? 'bg-primary-action' : 'bg-gray-300'
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<div
|
||||
className={`
|
||||
size-6 shrink-0 rounded-full flex items-center justify-center
|
||||
${circleBg}
|
||||
`}
|
||||
>
|
||||
{isCompleted && (
|
||||
<Check className="size-3.5 text-primary-foreground stroke-[3]" />
|
||||
)}
|
||||
{isCurrent && (
|
||||
<div className="size-2 rounded-full bg-background" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLast && (
|
||||
<div className={`flex-1 h-0.5 ${nextLineBg}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full mt-3">
|
||||
{computedSteps.map((step, index) => {
|
||||
const isLast = index === computedSteps.length - 1
|
||||
const textColor =
|
||||
step.status === 'upcoming'
|
||||
? 'text-gray-500'
|
||||
: 'text-primary-action'
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<div className="w-6 shrink-0 flex justify-center">
|
||||
<span
|
||||
className={`
|
||||
text-[11px] font-medium text-center leading-tight
|
||||
w-16 -mx-5 ${textColor}
|
||||
`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{!isLast && <div className="flex-1" />}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderStepperStatus
|
||||
35
src/components/orders/order-time-address-info-alert.tsx
Normal file
35
src/components/orders/order-time-address-info-alert.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { ClockIcon, MapPinIcon } from "lucide-react";
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
|
||||
interface Props {
|
||||
order: OrderGroup;
|
||||
}
|
||||
|
||||
const OrderTimeAddressInfoAlert = ({ order }: Props) => {
|
||||
// زمان تخمینی (فعلاً ساده)
|
||||
const estimatedTime =
|
||||
order.delivery_type === "express"
|
||||
? "۳۰–۴۵ دقیقه دیگر"
|
||||
: order.scheduled_at
|
||||
? new Date(order.scheduled_at).toLocaleString("fa-IR")
|
||||
: "زمان مشخص نشده";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-start p-4 bg-teal-bg rounded-2xl my-4 mx-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="size-5 text-primary-action" />
|
||||
<p className="text-md font-bold text-primary-action">
|
||||
زمان تخمینی تحویل: {estimatedTime}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="size-5 text-primary-action" />
|
||||
<p className="text-sm text-gray-700">{order.full_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderTimeAddressInfoAlert;
|
||||
47
src/components/orders/orders-skeleton.tsx
Normal file
47
src/components/orders/orders-skeleton.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
|
||||
const OrdersSkeleton = () => {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-full rounded-2xl min-h-32 border border-gray-300 bg-background p-4 animate-pulse"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-full bg-gray-200 flex-shrink-0" />
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="w-32 h-4 bg-gray-200 rounded" />
|
||||
<div className="w-24 h-3 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 h-6 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-gray-200 my-4" />
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 w-full">
|
||||
<div className="col-span-2 flex items-center gap-1 overflow-x-auto pb-2">
|
||||
{Array.from({ length: 3 }).map((_, imgIndex) => (
|
||||
<div
|
||||
key={imgIndex}
|
||||
className="size-10 flex-shrink-0 rounded-lg bg-gray-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex flex-col items-end gap-2">
|
||||
<div className="w-20 h-3 bg-gray-200 rounded" />
|
||||
<div className="w-28 h-4 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-8 rounded-xl h-12 bg-gray-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersSkeleton;
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useState, useTransition, type ReactNode } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Minus, Plus, X, CheckCircle2 } from "lucide-react";
|
||||
import { Minus, Plus, X, CheckCircle2, Check } from "lucide-react";
|
||||
import { addToCart } from "@/lib/api/cart";
|
||||
import { toPersianDigits } from "@/lib/format/persian-digits";
|
||||
import type { ProductDetail } from "@/types/api";
|
||||
|
|
@ -41,17 +41,19 @@ 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">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
|
||||
{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))}
|
||||
|
|
@ -60,10 +62,16 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
|
|||
>
|
||||
<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)}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
|
||||
>
|
||||
<Plus 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" />
|
||||
|
|
@ -73,35 +81,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-[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>
|
||||
<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>
|
||||
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
|
||||
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
|
||||
<X className="h-4 w-4 text-emerald-500" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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="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="flex w-full items-center justify-between gap-4">
|
||||
<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">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<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-[5px] w-[134px] rounded-full bg-black" />
|
||||
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
35
src/components/profile/profile-exit-account.tsx
Normal file
35
src/components/profile/profile-exit-account.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
'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;
|
||||
38
src/components/profile/profile-menu-item.tsx
Normal file
38
src/components/profile/profile-menu-item.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'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;
|
||||
70
src/components/profile/profile-menu.tsx
Normal file
70
src/components/profile/profile-menu.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'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;
|
||||
11
src/components/profile/profile-page-head-line.tsx
Normal file
11
src/components/profile/profile-page-head-line.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react'
|
||||
|
||||
interface ProfilePageHeadLineInterface {
|
||||
title: String;
|
||||
}
|
||||
|
||||
const ProfilePageHeadLine = ({ title }: ProfilePageHeadLineInterface) => {
|
||||
return <h1 className="font-bold text-2xl">{title}</h1>
|
||||
}
|
||||
|
||||
export default ProfilePageHeadLine
|
||||
70
src/components/profile/profile-person-detail-card.tsx
Normal file
70
src/components/profile/profile-person-detail-card.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'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,11 +4,20 @@ 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";
|
||||
|
||||
/** 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 }) {
|
||||
export function ProductGridCard({
|
||||
product,
|
||||
cartItem,
|
||||
}: {
|
||||
product: ProductListItem;
|
||||
cartItem: CartItem | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
|
@ -43,12 +52,13 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
|
|||
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);
|
||||
}
|
||||
|
|
@ -57,67 +67,83 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
|
|||
}
|
||||
|
||||
return (
|
||||
<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)]">
|
||||
<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)]">
|
||||
{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-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
|
||||
<div className="relative h-32.5 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-gradient-to-t from-black/40 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col justify-between p-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{disabled ? (
|
||||
<span className="text-center text-label text-gray-500">ناموجود</span>
|
||||
) : quantity === 0 ? (
|
||||
{!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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
onClick={
|
||||
quantity === 0 ? handleAdd : () => handleChange(quantity + 1)
|
||||
}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<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={() => handleChange(quantity + 1)}
|
||||
disabled={isPending}
|
||||
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>
|
||||
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
|
||||
|
||||
{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 bg-blue-bg text-primary-action disabled:opacity-40"
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,31 @@
|
|||
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-[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="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="flex w-full items-center justify-between">
|
||||
<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">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<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-[5px] w-[134px] rounded-full bg-black" />
|
||||
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ 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) {
|
||||
|
|
@ -20,35 +22,52 @@ 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="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="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="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 rounded-[10px] border border-teal-border p-2">
|
||||
<div className="mt-4 flex items-center gap-2 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>
|
||||
|
|
@ -56,7 +75,7 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
|
|||
{store.phone_number && (
|
||||
<a
|
||||
href={`tel:${store.phone_number}`}
|
||||
className="mt-2 flex items-center gap-2 rounded-[10px] border border-teal-border p-2"
|
||||
className="mt-2 flex items-center gap-2 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,10 +8,14 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
// 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",
|
||||
outline: "border border-border bg-transparent hover:bg-muted",
|
||||
ghost: "bg-transparent hover:bg-muted",
|
||||
ghost: "border-0 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 border border-white bg-white/80 backdrop-blur-md"
|
||||
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full bg-black/35 backdrop-blur-lg"
|
||||
>
|
||||
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
|
||||
</Link>
|
||||
|
|
|
|||
38
src/components/wallet/transaction-history.tsx
Normal file
38
src/components/wallet/transaction-history.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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;
|
||||
49
src/components/wallet/transaction-item.tsx
Normal file
49
src/components/wallet/transaction-item.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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;
|
||||
52
src/components/wallet/wallet-blanc-card.tsx
Normal file
52
src/components/wallet/wallet-blanc-card.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'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;
|
||||
8
src/lib/api/account.ts
Normal file
8
src/lib/api/account.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"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,5 +1,6 @@
|
|||
"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";
|
||||
|
|
@ -10,8 +11,21 @@ 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>> {
|
||||
return callApi(() => winofyFetch<OrderGroup>("/v1/checkout/", { method: "POST", body: input }));
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,17 +46,19 @@ function indent(text: string): string {
|
|||
|
||||
/**
|
||||
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function logWinofyRequest(method: string, url: string, body: unknown) {
|
||||
export function logApiRequest(service: string, method: string, url: string, body: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
console.log(`\n${CYAN}→ [${service}]${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
|
||||
}
|
||||
|
||||
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
|
||||
export function logApiResponse(service: string, method: string, url: string, status: number, data: unknown) {
|
||||
if (!DEBUG) return;
|
||||
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
console.log(`${statusColor(status)}← [${service}] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
|
||||
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
|
||||
}
|
||||
|
|
|
|||
65
src/lib/api/gooyal-accounts.ts
Normal file
65
src/lib/api/gooyal-accounts.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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 { logWinofyRequest, logWinofyResponse } from "./dev-logger";
|
||||
import { logApiRequest, logApiResponse } 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}`);
|
||||
}
|
||||
|
||||
logWinofyRequest(method, url.toString(), body);
|
||||
logApiRequest("winofy", 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) {
|
||||
logWinofyResponse(method, url.toString(), res.status, null);
|
||||
logApiResponse("winofy", method, url.toString(), res.status, null);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
logWinofyResponse(method, url.toString(), res.status, data);
|
||||
logApiResponse("winofy", method, url.toString(), res.status, data);
|
||||
|
||||
if (!res.ok) {
|
||||
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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!;
|
||||
|
|
@ -34,11 +35,16 @@ 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,
|
||||
});
|
||||
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
|
||||
|
||||
// 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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(),
|
||||
|
|
@ -48,6 +54,12 @@ 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);
|
||||
}
|
||||
|
|
|
|||
42
src/lib/providers/account-provider.tsx
Normal file
42
src/lib/providers/account-provider.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"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;
|
||||
}
|
||||
42
src/lib/providers/order-detail-provider.tsx
Normal file
42
src/lib/providers/order-detail-provider.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import {createContext, useContext, type ReactNode} from "react";
|
||||
import type {Order, OrderGroup} from "@/types/api";
|
||||
|
||||
type OrderDetailContextValue = {
|
||||
data: OrderGroup | null;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const OrderDetailContext = createContext<OrderDetailContextValue | undefined>(undefined);
|
||||
|
||||
export function OrderDetailProvider({
|
||||
children,
|
||||
order,
|
||||
error = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
order: OrderGroup | null;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<OrderDetailContext.Provider
|
||||
value={{
|
||||
data: order,
|
||||
error,
|
||||
isLoading: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OrderDetailContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrderDetail() {
|
||||
const ctx = useContext(OrderDetailContext);
|
||||
if (ctx === undefined) {
|
||||
throw new Error("useOrderDetail must be used within OrderDetailProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
42
src/lib/providers/orders-provider.tsx
Normal file
42
src/lib/providers/orders-provider.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { OrderGroup } from "@/types/api";
|
||||
|
||||
type OrdersContextValue = {
|
||||
data: OrderGroup[] | null;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const OrdersContext = createContext<OrdersContextValue | undefined>(undefined);
|
||||
|
||||
export function OrdersProvider({
|
||||
children,
|
||||
orderGroups,
|
||||
error = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
orderGroups: OrderGroup[] | null;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<OrdersContext.Provider
|
||||
value={{
|
||||
data: orderGroups,
|
||||
error,
|
||||
isLoading: false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OrdersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrders() {
|
||||
const ctx = useContext(OrdersContext);
|
||||
if (ctx === undefined) {
|
||||
throw new Error("useOrders must be used within OrdersProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
49
src/lib/utils/account-utils.ts
Normal file
49
src/lib/utils/account-utils.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'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 || 'کاربر';
|
||||
}
|
||||
42
src/lib/utils/priceUtils.ts
Normal file
42
src/lib/utils/priceUtils.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* 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,3 +310,42 @@ 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