Compare commits

...
Sign in to create a new pull request.

29 commits

Author SHA1 Message Date
7e9ab5d68f change more files 2026-09-12 16:32:41 +03:30
a9268b8a94 Merge branch 'fix/WNPT-735' into feature/WNPT-737
# Conflicts:
#	src/lib/api/gooyal-accounts.ts
2026-09-07 09:55:33 +03:30
4e3eb2de9f dynomic orders first commit 2026-09-07 09:30:09 +03:30
Afra ‌
b548c59484 fix: single order checkout added 2026-09-05 16:24:45 +03:30
Afra ‌
c2b4c4a690 fix: minor modification on cart page's design 2026-09-05 14:00:55 +03:30
Afra ‌
6c5d02ddd0 feat: address pages added 2026-09-05 11:37:15 +03:30
Afra ‌
2048e99bf2 feat: C08 page implemented 2026-09-02 15:14:43 +03:30
464f2b34a2 Merge branch 'develop' into feature/WNPT-737 2026-09-02 12:56:36 +03:30
2363fc0f88 Merge pull request 'feature/WNPT-752' (#7) from feature/WNPT-752 into develop
Reviewed-on: #7
2026-09-02 05:26:05 -04:00
1fe7c41bdc Merge branch 'develop' into feature/WNPT-752 2026-09-02 12:51:04 +03:30
bae58c8548 Merge pull request 'feature/WNPT-735' (#6) from feature/WNPT-735 into feature/WNPT-752
Reviewed-on: #6
2026-09-02 05:06:11 -04:00
61b82b0fb2 Merge pull request 'feature/WNPT-740' (#3) from feature/WNPT-740 into develop
Reviewed-on: #3
2026-09-02 04:02:09 -04:00
Afra ‌
ef75ed1a0c feat: checkout page implemented 2026-09-02 10:40:48 +03:30
Afra ‌
08e4a1a98a feat: checkout page implemented 2026-09-02 10:40:24 +03:30
Afra ‌
f94ee531a3 feat: checout page implemented 2026-09-02 01:55:25 +03:30
Afra ‌
adf31dc999 perf: cart page modified 2026-09-02 01:30:30 +03:30
Afra ‌
64e321dc3d perf: store and product pages' bottom bar modified 2026-09-02 00:38:22 +03:30
Afra ‌
418a63734f perf: product detail page modified 2026-09-02 00:29:50 +03:30
89d2a090e1 Merge pull request 'perf: logger + store page modification' (#4) from feature/WNPT-734 into develop
Reviewed-on: #4
2026-09-01 02:53:21 -04:00
59ef2707b2 fix: update heading background color in OrderDetail component 2026-09-01 09:58:32 +03:30
f4259dee26 feat: add order components and server error handling 2026-09-01 09:43:44 +03:30
8ff9266c55 order detail first commit 2026-08-31 16:23:06 +03:30
Afra ‌
cc928871cc perf: logger + store page modification 2026-08-31 16:03:11 +03:30
b0ce5ce2f9 feat(profile): implement account fetching and context provider for user profile
feat(wallet): enhance wallet UI and transaction item display
fix(profile): update profile detail card to handle loading and error states
style: improve UI consistency across profile and wallet components
2026-08-30 13:28:27 +03:30
6d917c6cfa change more files 2026-08-30 10:44:04 +03:30
cbd0b4bebe feat(profile): complete profile and wallet UI with actions
- Add ProfileLayout with session authentication guard
- Create ProfilePage and WalletPage screen components
- Implement reusable profile UI elements:
  * ProfileMenu and ProfileMenuItem for navigation
  * ProfileExitAccount with logout handler
  * ProfilePersonDetailCard displaying user info
  * ProfilePageHeadLine for section titles
- Implement wallet section:
  * WalletBlancCard showing balance with charge/withdraw buttons
  * TransactionHistory listing recent transactions
  * TransactionItem with dynamic deposit/withdraw styling
- Use client components for interactive behaviours (logout, navigation)
- Apply consistent styling using Tailwind CSS and Lucide icons
2026-08-29 14:19:29 +03:30
41e86d30b3 add empty order component & server error component & orders skeleton & order item 2026-08-29 11:48:30 +03:30
2cd5a9987d add priceUtils -- formatter price 2026-08-29 08:09:32 +03:30
2c6c8ad6e1 imp profile new design & wallet new design 2026-08-29 07:45:53 +03:30
77 changed files with 2985 additions and 852 deletions

10
.idea/.gitignore generated vendored Normal file
View 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

View 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
View 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
View 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
View 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
View file

@ -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",

View file

@ -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",

View 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

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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}
</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>
</div>
{!group.meets_minimum_order && (
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
</p>
)}
</section>
))}
<div className="sticky bottom-4 flex flex-col gap-2 rounded-2xl border border-border bg-background p-4 shadow-lg">
<div className="flex justify-between font-semibold">
<span>مبلغ قابل پرداخت</span>
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
</div>
<Link href={hasBlockedGroup ? "#" : "/checkout"} aria-disabled={hasBlockedGroup}>
<Button size="lg" className="w-full" disabled={hasBlockedGroup}>
ادامه و ثبت سفارش
</Button>
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-28">
<div className="flex items-center justify-start gap-3">
<Link href="/" aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</Link>
<h1 className="text-[18px] font-bold leading-none text-foreground">
سبد خرید
</h1>
</div>
<div className="flex flex-col gap-8">
{cart.groups.map((group) => (
<StoreCartCard key={group.store.uuid} group={group}>
{!group.meets_minimum_order && (
<p className="mt-3 rounded-lg bg-error/10 px-3 py-2 text-xs text-error">
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
</p>
)}
<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>
);

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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>
}

View 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

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
<StoreInfoCard store={store} />
<section className="px-4 pt-2">
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
<section 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>
);
}

View file

@ -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>

View file

@ -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",
)}
>

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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);
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 {
await updateCartItemQuantity(item.uuid, nextQuantity);
setError(result.message);
}
router.refresh();
});
}
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">
{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 className={`flex flex-col gap-2 py-4 ${isPending ? "opacity-50" : ""}`}>
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
order — under this app's inherited dir="rtl", the first child renders at the
right edge and the last at the left. */}
<div className="flex items-start justify-start gap-3">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-muted">
{item.product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-col items-start gap-3 text-right m-2">
<span className="text-[14px] font-bold leading-none text-foreground">{item.product.name}</span>
<span className="text-[13px] leading-none text-gray-600">
{item.product.price.toLocaleString("fa-IR")} تومان
</span>
</div>
</div>
<div className="flex flex-1 flex-col gap-1">
<span className="text-sm font-medium">{item.product.name}</span>
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
<div className="flex items-center justify-end gap-3">
<div className="flex h-8 items-center gap-3 rounded-lg border border-gray-300 px-2">
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity - 1)}
aria-label="کاهش تعداد"
>
<Minus className="h-3.5 w-3.5 text-foreground" />
</button>
<span className="min-w-3 text-center text-[13px] font-bold leading-none text-foreground">
{item.quantity.toLocaleString("fa-IR")}
</span>
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity + 1)}
aria-label="افزایش تعداد"
>
<Plus className="h-3.5 w-3.5 text-foreground" />
</button>
</div>
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity - 1)}
className="flex h-6 w-6 items-center justify-center text-sm"
aria-label="کاهش تعداد"
onClick={() => changeQuantity(0)}
aria-label="حذف از سبد"
className="flex h-8 w-8 items-center justify-center disabled:opacity-40"
>
−
</button>
<span className="w-5 text-center text-sm">{item.quantity}</span>
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity + 1)}
className="flex h-6 w-6 items-center justify-center text-sm"
aria-label="افزایش تعداد"
>
+
<Trash2 className="h-4.5 w-4.5 text-error" />
</button>
</div>
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
{error && <p className="text-end text-xs text-error">{error}</p>}
</div>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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="&copy; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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

View 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;

View 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;

View 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;

View 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;

View 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

View 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

View 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;

View 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;

View file

@ -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">
<span
className={`inline-flex items-center gap-1 text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}
>
{product.is_out_of_stock ? "ناموجود" : "موجود"}
{!product.is_out_of_stock && <Check className="h-3.5 w-3.5" />}
</span>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setQuantity((q) => 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>
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
<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>
</>
);

View 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;

View 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;

View 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;

View 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

View 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;

View file

@ -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">
<Link href={`/products/${product.uuid}`}>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
</Link>
<p className="text-right text-body-medium font-extrabold text-primary-action">
{product.price.toLocaleString("fa-IR")} تومان
</p>
</div>
<div className="flex flex-1 flex-col gap-1.5 p-3">
<Link href={`/products/${product.uuid}`}>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">
{product.name}
</h3>
</Link>
<p className="text-right text-body-medium font-extrabold text-primary-action">
{product.price.toLocaleString("fa-IR")} تومان
</p>
{disabled && (
<span className="mt-auto text-center text-label text-gray-500">
ناموجود
</span>
)}
</div>
{disabled ? (
<span className="text-center text-label text-gray-500">ناموجود</span>
) : quantity === 0 ? (
<button
type="button"
onClick={handleAdd}
disabled={isPending}
aria-label="افزودن به سبد"
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
) : (
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
{!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={() => handleChange(quantity + 1)}
onClick={
quantity === 0 ? handleAdd : () => 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"
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>
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
<button
type="button"
onClick={() => handleChange(quantity - 1)}
disabled={isPending}
aria-label="کاهش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</button>
{quantity > 0 && (
<>
<span className="flex h-full w-8 shrink-0 items-center justify-center text-center text-[18px] font-bold leading-none text-foreground">
{quantity.toLocaleString("fa-IR")}
</span>
<button
type="button"
onClick={() => handleChange(quantity - 1)}
disabled={isPending}
aria-label="کاهش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-blue-bg p-0 text-primary-action disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</button>
</>
)}
</div>
)}
</div>
</div>
)}
</article>
);
}

View file

@ -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>
);
}

View file

@ -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" />

View file

@ -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",

View file

@ -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>

View 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;

View 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;

View 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
View 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/");
}

View file

@ -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;
}

View file

@ -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))}`);
}

View 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;
}

View file

@ -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;

View file

@ -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);
}

View 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;
}

View 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;
}

View 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;
}

View 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 || 'کاربر';
}

View 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)} تومان`;
};

View file

@ -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;
}