Compare commits

...

13 commits

Author SHA1 Message Date
676ef9cfad Merge pull request 'fix/WNPT-735' (#9) from fix/WNPT-735 into develop
Reviewed-on: #9
2026-09-12 13:44:19 -04:00
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
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
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
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
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
38 changed files with 1498 additions and 556 deletions

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

@ -26,10 +26,7 @@ export default async function CartPage() {
);
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
{/* 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. */}
<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" />
@ -49,7 +46,11 @@ export default async function CartPage() {
)}
<Link
href={group.meets_minimum_order ? `/checkout/${group.store.uuid}` : "#"}
href={
group.meets_minimum_order
? `/checkout/${group.store.uuid}`
: "#"
}
aria-disabled={!group.meets_minimum_order}
className="mt-4 flex justify-center"
>
@ -65,19 +66,23 @@ export default async function CartPage() {
))}
</div>
<div className="flex flex-row justify-between border-t border-gray-200 pt-4 px-5">
<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")} کالا)
<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>
<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>
</main>
);

View file

@ -4,10 +4,16 @@ 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 }: PageProps<"/checkout/[store]/confirm">) {
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([
@ -19,6 +25,8 @@ export default async function ConfirmPaymentPage({ params }: PageProps<"/checkou
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]. */}
@ -29,7 +37,11 @@ export default async function ConfirmPaymentPage({ params }: PageProps<"/checkou
<h1 className="text-[18px] font-bold leading-none text-foreground">تأیید و پرداخت</h1>
</div>
<CheckoutPayment group={group} addresses={addresses} />
{isDelivery && (
<CheckoutAddress address={address} returnTo={`/checkout/${store}/confirm?fulfillment=delivery`} />
)}
<CheckoutPayment group={group} addresses={addresses} isDelivery={isDelivery} />
</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,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

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

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

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

@ -94,9 +94,12 @@ export function CheckoutFulfillment({ group }: { group: CartGroup }) {
</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)]">
{/* Pickup has its own confirm+pay screen (no address needed); delivery still
goes to the general /checkout form until that flow's design is provided. */}
<Link href={pickup ? `/checkout/${group.store.uuid}/confirm` : "/checkout"} className="w-full">
{/* 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"

View file

@ -12,14 +12,22 @@ const PAYMENT_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "cash_on_delivery", label: "پرداخت در محل" },
];
export function CheckoutPayment({ group, addresses }: { group: CartGroup; addresses: Address[] }) {
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 flow — no delivery fee, matches the fulfillment screen's "دریافت حضوری" total.
const payable = group.items_subtotal;
// Pickup — no delivery fee, matches the fulfillment screen's "دریافت حضوری" total.
const payable = group.items_subtotal + (isDelivery ? group.delivery_fee : 0);
function handlePay() {
setError(null);
@ -29,14 +37,16 @@ export function CheckoutPayment({ group, addresses }: { group: CartGroup; addres
return;
}
startTransition(async () => {
// submitCheckout processes the whole cart at once (splits into per-store orders
// server-side), not just this store — see PageProps note on the confirm page.
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}`);

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

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

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

@ -95,4 +95,4 @@ export async function exchangeOtpForToken(phoneNumber: string, otp: string) {
throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description);
}
return data as GooyalTokenResponse;
}
}

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