Merge branch 'develop' into feature/WNPT-737

This commit is contained in:
Mohammadali 2026-09-02 12:56:36 +03:30
commit 464f2b34a2
43 changed files with 1628 additions and 441 deletions

215
FRONTEND_GUIDE.md Normal file
View file

@ -0,0 +1,215 @@
# Winofy Frontend Guide
Internal engineering reference for the Winofy customer/seller web frontend. Source comments elsewhere in this repo (`errors.ts`, `proxy.ts`, `lib/location/client.ts`, ...) cite section numbers from this file directly — keep the numbering stable when editing.
## 1. Overview
Winofy is a Persian/Farsi (RTL) hyperlocal multi-vendor marketplace: customers browse nearby stores by neighborhood/city or GPS location, order products, and track deliveries; sellers manage their own storefront, products, and orders. This repo is the web frontend, built on Next.js 16 against two backends:
- **Winofy API** (Django/DRF) — stores, products, cart, checkout, orders, addresses, reviews. Staging: `https://winofy-staging.winsoo.ir/api`. **No CORS** — every call must happen server-side (Server Component, Server Action, or Route Handler), never directly from the browser.
- **Gooyal accounts** — the shared OAuth2 identity provider used across Winsoo group products, for phone + OTP login. Staging: `https://accounts-staging.gooyal.ir`. CORS is open there, so the login form calls it directly from the browser.
## 2. Tech stack
- Next.js 16.3.1, App Router, Turbopack, `output: "standalone"`
- React 19.2.8 / TypeScript 5 (`strict`)
- Tailwind CSS v4 (`@theme inline` token system — see §5)
- Radix UI primitives (dialog, select, tabs, toast, checkbox, label) + `class-variance-authority` / `tailwind-merge`, wrapped by the small `cn()`-based component layer in `src/components/ui/`
- `jose` — JWE encryption for the session cookie
- `lucide-react` — icon set
- `@tanstack/react-query` — the provider is wired in (`src/lib/query-provider.tsx` → `src/app/layout.tsx`), but nothing in the app calls `useQuery`/`useMutation` yet; all data currently comes from Server Components. The plumbing exists if you're the first to need client-side fetching.
- `zod` — installed, not yet used anywhere. Same note as above.
## 3. Getting started
Prereqs: Node 24 (matches the Docker image), npm.
```bash
git clone <repo>
cd winofy
npm install
cp .env.example .env.local # fill in real values — ask a teammate for staging Gooyal credentials
npm run dev # http://localhost:3000
```
Scripts (`package.json`):
| Script | Runs |
|---|---|
| `npm run dev` | `next dev` (Turbopack) |
| `npm run build` | `next build` |
| `npm run start` | `next start` (the standalone build) |
| `npm run lint` | ESLint, flat config (`eslint-config-next`) |
No test runner is configured yet.
## 4. Architecture
### 4.1 Route structure
App Router route groups split the app by audience and auth requirement:
- `(auth)/login` — phone + OTP login (Gooyal)
- `(customer)/` — pages without the persistent shop header (home, location onboarding)
- `(customer)/(shop)/` — everything with the shop chrome: stores, products, cart, checkout, orders, addresses, profile
- `(seller)/seller` — seller-side dashboard (early stage)
- `src/proxy.ts` — Next 16's renamed `middleware.ts` (see §4.3)
- `src/app/api/*` — same-origin route handlers, used only where the browser needs to call something itself (session probe, logout, the neighborhood-search proxy — see §4.6)
### 4.2 Winofy API client & error envelope
All calls to the Winofy API go through `winofyFetch()` in `src/lib/api/winofy.ts`. It:
- prefixes `WINOFY_API_BASE_URL`, attaches query params, JSON-encodes the body
- attaches `Authorization: Bearer <accessToken>` from the session, unless called with `{ auth: false }`
- always sets `cache: "no-store"` — nothing from this API is safe to cache past the current request
- logs every request/response in dev via `src/lib/api/dev-logger.ts` (pretty-printed, truncated, color-coded by status) — this is your Network tab, since these calls never reach the browser
- throws `ApiError` on non-2xx, parsed from the API's error envelope:
```json
{
"success": false,
"status_code": 400,
"status_message": "...",
"details": { "message": "...", "error": "...", "timestamp": "..." }
}
```
`details.message` is either a plain string or a `{ field: string[] }` map of validation errors — `ApiError.fieldErrors` normalizes that for forms.
Server Actions wrap `winofyFetch` calls in `callApi()` (`src/lib/api/result.ts`), which never throws — it returns a typed `ActionResult<T>` (`{ ok: true, data }` or `{ ok: false, status, message, fieldErrors }`), so client components can branch on failure without try/catch.
### 4.3 Auth & sessions
Login is phone number + OTP against Gooyal, via the OAuth2 password grant with `auth_fields=phone_number:otp`. Two client modules exist because Gooyal is called from two different places:
- `src/lib/auth/gooyal-client.ts` — browser-side (`"use client"`), used by the login/OTP form. Gooyal's CORS is open, so this calls `accounts-staging.gooyal.ir` directly — it does **not** proxy through our own API routes. Uses the `NEXT_PUBLIC_*` env vars (§8).
- `src/lib/auth/gooyal.ts` — server-side, used only for refresh-token exchange. Uses the non-public env vars.
Once tokens come back, `createSession()` (`src/lib/auth/session.ts`) encrypts them into a JWE (`jose`, `alg: dir`, `enc: A256GCM`) and stores it as the `winofy_session` httpOnly cookie — a 30-day cookie lifetime, independent of the much shorter-lived Gooyal access token inside it.
`getValidSession()` is the actual source of truth for "is this user logged in": it decrypts the cookie, and if the access token is expired (30s skew), transparently refreshes it and re-persists the new session — deduped per-request via React's `cache()`. Prefer this over the raw `getSession()` everywhere.
`src/proxy.ts` (Next 16 renamed `middleware.ts` → `proxy.ts`; the export is now `export default function proxy(request)`, not `middleware`) does an **optimistic, cookie-presence-only** check against `AUTH_REQUIRED_PREFIXES` and redirects to `/login?next=...` if the cookie is missing entirely. It deliberately does not decrypt or validate the session — that would mean an extra round-trip on every request. A present-but-expired-and-unrefreshable session still reaches the page; that's what `requireSession(path)` is for — call it at the top of any page under an auth-required prefix, before fetching data, so a dead session redirects cleanly instead of the page crashing on an uncaught 401.
Cookie writes (`createSession`/`deleteSession`) are wrapped in try/catch: Next.js forbids mutating cookies during a plain Server Component render (only Server Actions and Route Handlers may), but `getValidSession()` can legitimately be called from either. When called from a render, a refreshed token is still used for the rest of that request — it just silently fails to persist, and the next request repeats the refresh. That's intentional, not a bug to "fix" by moving the call elsewhere.
### 4.4 API layer organization
`src/lib/api/` is organized by feature, one file per resource, all built on `winofyFetch`:
```
addresses.ts cart.ts catalog.ts checkout.ts
locations.ts media.ts notifications.ts orders.ts
reviews.ts stores.ts result.ts errors.ts
winofy.ts dev-logger.ts
seller/
analytics.ts orders.ts products.ts reviews.ts store.ts
```
Mutations (`add/update/remove/create/cancel...`) are Server Actions, called directly from client components as props — no fetch/API-route indirection. Reads are plain async functions called from Server Components. There's no separate "actions" layer — an earlier version split reads/writes into `src/lib/actions/`, and it was folded into these feature files because the split added indirection without a real benefit.
### 4.5 Data fetching pattern
Almost everything renders server-side: pages are `async function` Server Components that call `src/lib/api/*` reads directly and pass plain data down as props. This is why requests are invisible in Chrome's Network tab — they never leave the Next.js server process. Two consequences worth knowing before you go looking for a bug in the wrong place:
- To *see* these requests, read the dev logger output in your terminal (or `next.config.ts`'s `logging.fetches`, which shows URL/status/timing only, no body).
- Client-side navigation between pages (e.g. clicking a store card) can show up in the Network tab as just an RSC fetch (`?_rsc=...`). The store-detail data fetch still happened server-side to produce that payload — it's just bundled into that one request instead of a separate visible `GET /stores/:id`.
Cart/mutation UI (`ProductGridCard`, `ProductPurchasePanel`, etc.) is the exception: those are client components that call Server Actions via `useTransition`, update local state only after the action confirms success, then `router.refresh()` to reconcile with the true server state.
### 4.6 Location & neighborhoods
Location is stored client-side as a plain (unencrypted — not sensitive) cookie, `winofy_location`, via `src/lib/location/{client,server,types}.ts`. Two modes:
```ts
{ mode: "geo"; lat; lng }
{ mode: "manual"; cityUuid; cityName; neighborhoodUuid; neighborhoodName }
```
The home page (`(customer)/page.tsx`) reads it server-side to filter `getStores()`; the location picker (`location/search`) writes it client-side after a manual neighborhood pick or a successful geolocation read, and can clear it back to "no location selected."
Neighborhood **search** goes through a same-origin route handler, `src/app/api/locations/neighborhoods/route.ts`, rather than the client calling the Winofy API directly — necessary because the browser can't reach the non-CORS Winofy API itself. That handler also patches a real backend gap (see gap 1 below).
Known gaps in this area, numbered so future work can reference them:
1. Backend does not implement server-side neighborhood search filtering — worked around client-of-the-route-handler-side against both `name` and `city.name`.
2. The location cookie is 180 days — deliberate, so the L01 "share your location" onboarding screen doesn't refire every session.
3. Geolocation errors are surfaced by `GeolocationPositionError.code` (`PERMISSION_DENIED` / `POSITION_UNAVAILABLE` / `TIMEOUT`) with distinct Persian copy per case. macOS's `kCLErrorLocationUnknown` surfaces here as `POSITION_UNAVAILABLE` — that's CoreLocation reporting "no fix yet," not a frontend bug.
## 5. Design system
- RTL throughout: `<html dir="rtl" lang="fa">` in `src/app/layout.tsx`. Write layout CSS RTL-first (`text-right`, logical properties) rather than retrofitting an LTR layout.
- Font: Vazirmatn (`next/font/google`, `arabic` + `latin` subsets), exposed as the `--font-vazirmatn` CSS variable and mapped to Tailwind's `font-sans`.
- All design tokens live in `src/app/globals.css` as plain hex custom properties under `:root`, re-exposed to Tailwind via `@theme inline`. **Tailwind v4's built-in palette (emerald, slate, etc.) is OKLCH internally** — close to, but not bit-for-bit, the hex values in the design spec — so every color that must match a spec exactly is defined as its own token here rather than borrowed from Tailwind's defaults. Adding a new spec color means adding a token here, not reaching for e.g. `emerald-500` directly.
- Persian digit handling: `src/lib/format/persian-digits.ts` (`toPersianDigits` / `toLatinDigits`) — needed anywhere user-facing numbers are displayed, or anywhere user-typed numbers (phone, OTP) are parsed, since Persian keyboards type Persian-Arabic digit glyphs that fail plain `\d` regexes.
- Iranian week: Saturday is the first day. Convert from JS's `Date.getDay()` (Sunday = 0) with `(jsDay + 1) % 7` wherever a weekday needs to be shown.
## 6. Docker & deployment
Multi-stage `Dockerfile`, `output: "standalone"` — the runtime image ships only `server.js`, `.next/static`, and `public/`, runs as a non-root user, listens on `$PORT` (default `3000`).
The one thing not to get wrong: **`NEXT_PUBLIC_*` vars are baked into the client bundle at *build* time.** Everything else (API base URL, Gooyal secret, session secret) is read at container *runtime* instead, and must never be baked into an image layer.
```bash
cp .env.example .env.local
docker compose --env-file .env.local up --build
```
The `--env-file .env.local` flag is required — without it, Compose looks for a `.env` file (this repo doesn't have one) to fill the `NEXT_PUBLIC_*` build args in `docker-compose.yml`, and you'll silently get a client bundle built with empty values.
Plain `docker build`/`run` needs the same `NEXT_PUBLIC_*` values passed explicitly as `--build-arg`s (see `README.md`) plus `--env-file .env.local` at `docker run` time for everything else.
## 7. Known backend gaps
These are backend-side, not something to "fix" in this repo — flagged so you don't spend time debugging frontend code that's actually correct:
- **Cart item `PATCH`/`DELETE` return `403 permission_denied`** on staging, for both the per-item endpoint and the collection endpoints, with a token that can otherwise `GET`/`POST` the same cart. Blocks quantity-decrement and remove-from-cart app-wide until the backend/OAuth scope issue is resolved.
- **Addresses and order-groups `GET` return `403`** — current OAuth scope doesn't cover them.
- **Neighborhood search isn't filtered server-side** — worked around client-side, see §4.6 gap 1.
## 8. Environment variables
Copy `.env.example` to `.env.local` and fill in real values — never commit `.env.local`.
| Variable | Scope | Purpose |
|---|---|---|
| `WINOFY_API_BASE_URL` | server-only | Base URL of the Winofy Django/DRF API, e.g. `https://winofy-staging.winsoo.ir/api` |
| `GOOYAL_ACCOUNTS_BASE_URL` | server-only | Gooyal OAuth base, used for server-side refresh-token calls |
| `GOOYAL_CLIENT_ID` | server-only | OAuth client id, server-side calls |
| `GOOYAL_CLIENT_SECRET` | server-only | OAuth client secret, server-side calls |
| `GOOYAL_OAUTH_SCOPE` | server-only | Space-separated OAuth scope string, server-side calls |
| `NEXT_PUBLIC_GOOYAL_ACCOUNTS_BASE_URL` | public (client bundle) | Same Gooyal base, usable from the browser — the login form talks to Gooyal directly |
| `NEXT_PUBLIC_GOOYAL_CLIENT_ID` | public (client bundle) | Client id for the browser-side OAuth exchange |
| `NEXT_PUBLIC_GOOYAL_CLIENT_SECRET` | public (client bundle) | Intentionally public — the browser must send this in a Basic Auth header to complete the OTP token exchange directly against Gooyal, so it can never actually be kept confidential in a client-side flow. Gooyal's client registration for this app is configured accordingly; treat it like a client id, not a real secret |
| `NEXT_PUBLIC_GOOYAL_OAUTH_SCOPE` | public (client bundle) | Scope string for the browser-side OAuth exchange |
| `SESSION_SECRET` | server-only | 32-byte base64 key used to encrypt the session cookie (JWE). Generate with `openssl rand -base64 32` |
Docker note: the `NEXT_PUBLIC_*` values must be supplied as build args (they're compiled into the client bundle), not just runtime env — see §6.
## 9. Folder structure reference
```
src/
├─ app/
│ ├─ (auth)/login/
│ ├─ (customer)/
│ │ ├─ (shop)/
│ │ │ ├─ addresses/ cart/ checkout/ orders/
│ │ │ ├─ order-groups/[uuid]/ products/[uuid]/ stores/[uuid]/
│ │ │ ├─ profile/ layout.tsx
│ │ ├─ location/{error,permission,search}/
│ │ ├─ page.tsx (home) error.tsx
│ ├─ (seller)/seller/
│ ├─ api/{auth/{logout,session}, locations/neighborhoods}/route.ts
│ └─ layout.tsx globals.css
├─ components/
│ ├─ auth/ customer/ home/ location/ product/ store/ ui/
├─ lib/
│ ├─ api/ — §4.2 / §4.4
│ ├─ auth/ — §4.3
│ ├─ location/ — §4.6
│ ├─ format/ validation/ utils/ order-status.ts query-provider.tsx
├─ proxy.ts — §4.3
└─ types/api.ts
```

149
package-lock.json generated
View file

@ -81,6 +81,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@ -290,28 +291,6 @@
"node": ">=6.9.0"
}
},
"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/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -683,9 +662,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -702,9 +678,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -721,9 +694,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -740,9 +710,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -759,9 +726,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -778,9 +742,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -797,9 +758,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -816,9 +774,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -835,9 +790,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -860,9 +812,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -885,9 +834,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -910,9 +856,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -935,9 +878,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -960,9 +900,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -985,9 +922,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1010,9 +944,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1280,9 +1211,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1299,9 +1227,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1318,9 +1243,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1337,9 +1259,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2282,9 +2201,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2302,9 +2218,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2322,9 +2235,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2342,9 +2252,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2506,6 +2413,7 @@
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -2516,6 +2424,7 @@
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -2565,6 +2474,7 @@
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
@ -2921,9 +2831,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2938,9 +2845,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2955,9 +2859,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2972,9 +2873,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2989,9 +2887,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3006,9 +2901,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3023,9 +2915,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3040,9 +2929,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3057,9 +2943,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3074,9 +2957,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3175,6 +3055,7 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3530,6 +3411,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@ -4146,6 +4028,7 @@
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4331,6 +4214,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -5818,9 +5702,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5842,9 +5723,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5866,9 +5744,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5890,9 +5765,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -6576,6 +6448,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6585,6 +6458,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -7361,6 +7235,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7523,6 +7398,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -7843,6 +7719,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

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,64 @@ export default async function CartPage() {
);
}
const totalItemLines = cart.groups.reduce(
(sum, g) => sum + g.items.length,
0,
);
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
<h1 className="text-lg font-bold">سبد خرید</h1>
{cart.groups.map((group) => (
<section key={group.store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
<Link href={`/stores/${group.store.uuid}`} className="font-medium">
{group.store.name}
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-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. */}
<div className="flex items-center justify-start gap-3">
<Link href="/" aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</Link>
<div className="divide-y divide-border">
{group.items.map((item) => (
<CartItemRow key={item.uuid} item={item} />
))}
</div>
<div className="flex flex-col gap-1 border-t border-border pt-2 text-sm">
<div className="flex justify-between text-muted-foreground">
<span>جمع کالاها</span>
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>هزینه ارسال</span>
<span>{group.delivery_fee.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex justify-between font-semibold">
<span>مجموع</span>
<span>{group.total.toLocaleString("fa-IR")} تومان</span>
</div>
<h1 className="text-[18px] font-bold leading-none text-foreground">
سبد خرید
</h1>
</div>
<div className="flex flex-col gap-8">
{cart.groups.map((group) => (
<StoreCartCard key={group.store.uuid} group={group}>
{!group.meets_minimum_order && (
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<p className="mt-3 rounded-lg bg-error/10 px-3 py-2 text-xs text-error">
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
</p>
)}
</section>
))}
<div className="sticky bottom-4 flex flex-col gap-2 rounded-2xl border border-border bg-background p-4 shadow-lg">
<div className="flex justify-between font-semibold">
<span>مبلغ قابل پرداخت</span>
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
</div>
<Link href={hasBlockedGroup ? "#" : "/checkout"} aria-disabled={hasBlockedGroup}>
<Button size="lg" className="w-full" disabled={hasBlockedGroup}>
ادامه و ثبت سفارش
</Button>
<Link
href={group.meets_minimum_order ? `/checkout/${group.store.uuid}` : "#"}
aria-disabled={!group.meets_minimum_order}
className="mt-4 flex justify-center"
>
<button
type="button"
disabled={!group.meets_minimum_order}
className="w-full rounded-xl bg-primary-action px-8 py-3 text-[14px] font-bold text-white disabled:opacity-40"
>
تکمیل سفارش
</button>
</Link>
</StoreCartCard>
))}
</div>
<div className="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")} کالا)
</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

@ -0,0 +1,35 @@
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 { CheckoutPayment } from "@/components/customer/checkout-payment";
export default async function ConfirmPaymentPage({ params }: PageProps<"/checkout/[store]/confirm">) {
const { store } = await params;
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();
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>
<CheckoutPayment group={group} addresses={addresses} />
</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

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

@ -1,59 +1,92 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus, Trash2 } from "lucide-react";
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import type { CartItem } from "@/types/api";
export function CartItemRow({ item }: { item: CartItem }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!error) return;
const timeout = setTimeout(() => setError(null), 3000);
return () => clearTimeout(timeout);
}, [error]);
function changeQuantity(nextQuantity: number) {
startTransition(async () => {
if (nextQuantity <= 0) {
await removeCartItem(item.uuid);
} else {
await updateCartItemQuantity(item.uuid, nextQuantity);
}
const result =
nextQuantity <= 0 ? await removeCartItem(item.uuid) : await updateCartItemQuantity(item.uuid, nextQuantity);
if (result.ok) {
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
return (
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
<div className={`flex flex-col gap-2 py-4 ${isPending ? "opacity-50" : ""}`}>
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
order — under this app's inherited dir="rtl", the first child renders at the
right edge and the last at the left. */}
<div className="flex items-start justify-start gap-3">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-muted">
{item.product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<span className="text-sm font-medium">{item.product.name}</span>
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
<div className="flex flex-col items-start gap-3 text-right m-2">
<span className="text-[14px] font-bold leading-none text-foreground">{item.product.name}</span>
<span className="text-[13px] leading-none text-gray-600">
{item.product.price.toLocaleString("fa-IR")} تومان
</span>
</div>
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
</div>
<div className="flex items-center justify-end gap-3">
<div className="flex h-8 items-center gap-3 rounded-lg border border-gray-300 px-2">
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity - 1)}
className="flex h-6 w-6 items-center justify-center text-sm"
aria-label="کاهش تعداد"
>
−
<Minus className="h-3.5 w-3.5 text-foreground" />
</button>
<span className="w-5 text-center text-sm">{item.quantity}</span>
<span className="min-w-3 text-center text-[13px] font-bold leading-none text-foreground">
{item.quantity.toLocaleString("fa-IR")}
</span>
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity + 1)}
className="flex h-6 w-6 items-center justify-center text-sm"
aria-label="افزایش تعداد"
>
+
<Plus className="h-3.5 w-3.5 text-foreground" />
</button>
</div>
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(0)}
aria-label="حذف از سبد"
className="flex h-8 w-8 items-center justify-center disabled:opacity-40"
>
<Trash2 className="h-4.5 w-4.5 text-error" />
</button>
</div>
{error && <p className="text-end text-xs text-error">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,110 @@
"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)]">
{/* 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">
<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,110 @@
"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 }: { group: CartGroup; addresses: Address[] }) {
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;
function handlePay() {
setError(null);
const addressUuid = addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid;
if (!addressUuid) {
setError("برای ثبت سفارش ابتدا یک آدرس اضافه کنید.");
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: "",
});
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

@ -2,7 +2,7 @@
import { useEffect, useState, useTransition, type ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus, X, CheckCircle2 } from "lucide-react";
import { Minus, Plus, X, CheckCircle2, Check } from "lucide-react";
import { addToCart } from "@/lib/api/cart";
import { toPersianDigits } from "@/lib/format/persian-digits";
import type { ProductDetail } from "@/types/api";
@ -41,17 +41,19 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
return (
<>
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
order — under this app's inherited dir="rtl", the first child renders at the
right edge and the last at the left, so "what should appear on the left" must
come last in JSX (stepper group, then its own "+" trailing last so it lands
leftmost within the group). */}
<div className="flex h-10 items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setQuantity((q) => q + 1)}
aria-label="افزایش تعداد"
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
<span
className={`inline-flex items-center gap-1 text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}
>
<Plus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
{product.is_out_of_stock ? "ناموجود" : "موجود"}
{!product.is_out_of_stock && <Check className="h-3.5 w-3.5" />}
</span>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
@ -60,10 +62,16 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
>
<Minus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
<button
type="button"
onClick={() => setQuantity((q) => q + 1)}
aria-label="افزایش تعداد"
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
>
<Plus className="h-4 w-4" />
</button>
</div>
<span className={`text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}>
{product.is_out_of_stock ? "ناموجود" : "موجود ●"}
</span>
</div>
<div className="h-px w-full bg-gray-300" />
@ -73,35 +81,35 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
{error && <p className="px-5 text-body-medium text-error">{error}</p>}
{showToast && (
<div className="fixed inset-x-0 bottom-[135px] z-20 mx-auto flex w-[358px] max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
<X className="h-4 w-4 text-emerald-500" />
</button>
<div className="fixed inset-x-0 bottom-33.75 z-20 mx-auto flex w-89.5 max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
<span className="flex-1 text-right text-[14px] font-bold leading-none text-emerald-500">
محصول به سبد خرید اضافه شد
</span>
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
<X className="h-4 w-4 text-emerald-500" />
</button>
</div>
)}
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-97.5 flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="flex w-full items-center justify-between gap-4">
<button
type="button"
onClick={handleAddToCart}
disabled={disabled || isPending}
className="flex h-[53px] w-[245px] items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
>
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
</button>
<div className="flex flex-col items-end gap-0.5">
<div className="flex flex-col items-start gap-2">
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
<span className="text-[18px] font-bold leading-none text-foreground">
{(product.price * quantity).toLocaleString("fa-IR")} تومان
</span>
</div>
<button
type="button"
onClick={handleAddToCart}
disabled={disabled || isPending}
className="flex h-13.25 w-61.25 items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
>
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
</button>
</div>
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
</div>
</>
);

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

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

@ -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 { logWinofyRequest, logWinofyResponse } 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}`);
}
}
logWinofyRequest?.(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) {
logWinofyResponse?.(method, url.toString(), res.status, null);
return undefined as T;
}
const data = await res.json().catch(() => null);
logWinofyResponse?.(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,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;
}