diff --git a/FRONTEND_GUIDE.md b/FRONTEND_GUIDE.md new file mode 100644 index 0000000..8375d4c --- /dev/null +++ b/FRONTEND_GUIDE.md @@ -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 +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 ` 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` (`{ 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: `` 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 +``` diff --git a/package-lock.json b/package-lock.json index 28b1c3a..003039d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/src/app/(customer)/(shop)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx index 31bb900..56ee906 100644 --- a/src/app/(customer)/(shop)/cart/page.tsx +++ b/src/app/(customer)/(shop)/cart/page.tsx @@ -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 ( -
-

سبد خرید

- - {cart.groups.map((group) => ( -
- - {group.store.name} - - -
- {group.items.map((item) => ( - - ))} -
- -
-
- جمع کالاها - {group.items_subtotal.toLocaleString("fa-IR")} تومان -
-
- هزینه ارسال - {group.delivery_fee.toLocaleString("fa-IR")} تومان -
-
- مجموع - {group.total.toLocaleString("fa-IR")} تومان -
-
- - {!group.meets_minimum_order && ( -

- حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است. -

- )} -
- ))} - -
-
- مبلغ قابل پرداخت - {cart.grand_total.toLocaleString("fa-IR")} تومان -
- - +
+ {/* 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. */} +
+ + +

+ سبد خرید +

+
+ +
+ {cart.groups.map((group) => ( + + {!group.meets_minimum_order && ( +

+ حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است. +

+ )} + + + + +
+ ))} +
+ +
+
+ + جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "} + {totalItemLines.toLocaleString("fa-IR")} کالا) + +

+ هر فروشگاه جداگانه پرداخت و ارسال می‌شود +

+
+ + {cart.grand_total.toLocaleString("fa-IR")} تومان +
); diff --git a/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx new file mode 100644 index 0000000..9411ca7 --- /dev/null +++ b/src/app/(customer)/(shop)/checkout/[store]/confirm/page.tsx @@ -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 ( +
+ {/* Same header pattern as /cart and /checkout/[store]. */} +
+ + + +

تأیید و پرداخت

+
+ + +
+ ); +} diff --git a/src/app/(customer)/(shop)/checkout/[store]/page.tsx b/src/app/(customer)/(shop)/checkout/[store]/page.tsx new file mode 100644 index 0000000..b295587 --- /dev/null +++ b/src/app/(customer)/(shop)/checkout/[store]/page.tsx @@ -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 ( +
+ {/* Same header as /cart, same DOM-order note: first child renders right, last left. */} +
+ + + +

سبد خرید

+
+ + + + +
+ ); +} diff --git a/src/app/(customer)/(shop)/products/[uuid]/page.tsx b/src/app/(customer)/(shop)/products/[uuid]/page.tsx index e2fbc5e..286a980 100644 --- a/src/app/(customer)/(shop)/products/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/products/[uuid]/page.tsx @@ -14,8 +14,8 @@ export default async function ProductDetailPage({ params }: PageProps<"/products if (!product) notFound(); return ( -
-
+
+
{product.image_url && ( // eslint-disable-next-line @next/next/no-img-element {product.name} @@ -24,9 +24,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
-
+

{product.name}

- + {product.store.name}

diff --git a/src/app/(customer)/(shop)/profile/(routes)/(landing)/page.tsx b/src/app/(customer)/(shop)/profile/(routes)/(landing)/page.tsx new file mode 100644 index 0000000..30cae4d --- /dev/null +++ b/src/app/(customer)/(shop)/profile/(routes)/(landing)/page.tsx @@ -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

+ + + +
+} diff --git a/src/app/(customer)/(shop)/profile/(routes)/wallet/page.tsx b/src/app/(customer)/(shop)/profile/(routes)/wallet/page.tsx new file mode 100644 index 0000000..5af139a --- /dev/null +++ b/src/app/(customer)/(shop)/profile/(routes)/wallet/page.tsx @@ -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 ( +
+ + + + +
+ ) +} + +export default WalletPage \ No newline at end of file diff --git a/src/app/(customer)/(shop)/profile/layout.tsx b/src/app/(customer)/(shop)/profile/layout.tsx new file mode 100644 index 0000000..dc8b338 --- /dev/null +++ b/src/app/(customer)/(shop)/profile/layout.tsx @@ -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> | null = null; + let error: string | null = null; + + try { + account = await getAccount(); + } catch (e: any) { + error = e?.message ?? "خطا در دریافت اطلاعات پروفایل"; + } + + return ( +
+ + {children} + +
+ ); +} \ No newline at end of file diff --git a/src/app/(customer)/(shop)/profile/page.tsx b/src/app/(customer)/(shop)/profile/page.tsx deleted file mode 100644 index 415b538..0000000 --- a/src/app/(customer)/(shop)/profile/page.tsx +++ /dev/null @@ -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 ( -
-

پروفایل

- - سفارش‌های من - - - آدرس‌های من - -
- -
-
- ); -} diff --git a/src/app/(customer)/(shop)/stores/[uuid]/page.tsx b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx index 9102f9a..ed111ff 100644 --- a/src/app/(customer)/(shop)/stores/[uuid]/page.tsx +++ b/src/app/(customer)/(shop)/stores/[uuid]/page.tsx @@ -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(); - storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item)); + storeGroup?.items.forEach((item) => + cartItemByProduct.set(item.product.uuid, item), + ); return (
-
+
{store.cover_image_url && ( // eslint-disable-next-line @next/next/no-img-element - + )}
+
+ +
- - -
-

توضیحات

-

{store.description}

+
+

+ توضیحات +

+

+ {store.description} +

-
+
-
+
{products.map((product) => ( - + ))} {products.length === 0 && ( -

محصولی یافت نشد.

+

+ محصولی یافت نشد. +

)}
- {storeGroup && } + {storeGroup && ( + + )}
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 60364b4..217ce00 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,8 +15,8 @@ export const metadata: Metadata = { export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - + + {children} diff --git a/src/components/auth/role-card.tsx b/src/components/auth/role-card.tsx index f247116..5cdddb1 100644 --- a/src/components/auth/role-card.tsx +++ b/src/components/auth/role-card.tsx @@ -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", )} > diff --git a/src/components/customer/add-to-cart-button.tsx b/src/components/customer/add-to-cart-button.tsx deleted file mode 100644 index f860672..0000000 --- a/src/components/customer/add-to-cart-button.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/components/customer/cart-item-row.tsx b/src/components/customer/cart-item-row.tsx index 923824b..df582a2 100644 --- a/src/components/customer/cart-item-row.tsx +++ b/src/components/customer/cart-item-row.tsx @@ -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(null); + + useEffect(() => { + if (!error) return; + const timeout = setTimeout(() => setError(null), 3000); + return () => clearTimeout(timeout); + }, [error]); function changeQuantity(nextQuantity: number) { startTransition(async () => { - if (nextQuantity <= 0) { - await removeCartItem(item.uuid); + const result = + nextQuantity <= 0 ? await removeCartItem(item.uuid) : await updateCartItemQuantity(item.uuid, nextQuantity); + + if (result.ok) { + router.refresh(); + } else if (result.status === 401) { + router.push(`/login?next=${encodeURIComponent(pathname)}`); } else { - await updateCartItemQuantity(item.uuid, nextQuantity); + setError(result.message); } - router.refresh(); }); } return ( -
-
- {item.product.image_url && ( - // eslint-disable-next-line @next/next/no-img-element - - )} +
+ {/* 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. */} +
+
+ {item.product.image_url && ( + // eslint-disable-next-line @next/next/no-img-element + + )} +
+
+ {item.product.name} + + {item.product.price.toLocaleString("fa-IR")} تومان + +
-
- {item.product.name} - {item.product.price.toLocaleString("fa-IR")} تومان -
-
+ +
+
+ + + {item.quantity.toLocaleString("fa-IR")} + + +
- {item.quantity} -
- {item.line_total.toLocaleString("fa-IR")} + + {error &&

{error}

}
); } diff --git a/src/components/customer/checkout-fulfillment.tsx b/src/components/customer/checkout-fulfillment.tsx new file mode 100644 index 0000000..a3e638a --- /dev/null +++ b/src/components/customer/checkout-fulfillment.tsx @@ -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 ( +
+
+

نحوه دریافت

+ + + + +
+ +
+
+ جمع کالاها + {group.items_subtotal.toLocaleString("fa-IR")} تومان +
+
+ هزینه ارسال + {deliveryFee.toLocaleString("fa-IR")} تومان +
+
+ مبلغ قابل پرداخت + + {payable.toLocaleString("fa-IR")} تومان + +
+
+ +
+ {/* 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. */} + + + +
+
+ ); +} diff --git a/src/components/customer/checkout-payment.tsx b/src/components/customer/checkout-payment.tsx new file mode 100644 index 0000000..d61de45 --- /dev/null +++ b/src/components/customer/checkout-payment.tsx @@ -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("wallet"); + const [error, setError] = useState(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 ( +
+
+

روش پرداخت

+ + {PAYMENT_OPTIONS.map((option) => { + const selected = method === option.value; + return ( + + ); + })} +
+ + {error &&

{error}

} + +
+
+ + {group.items.length.toLocaleString("fa-IR")} کالا از {group.store.name} + + {payable.toLocaleString("fa-IR")} تومان +
+
+ جمع کل + + {payable.toLocaleString("fa-IR")} تومان + +
+ +
+
+ ); +} diff --git a/src/components/customer/clear-cart-button.tsx b/src/components/customer/clear-cart-button.tsx new file mode 100644 index 0000000..708e0e7 --- /dev/null +++ b/src/components/customer/clear-cart-button.tsx @@ -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(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 ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/src/components/customer/store-cart-card.tsx b/src/components/customer/store-cart-card.tsx new file mode 100644 index 0000000..9500a32 --- /dev/null +++ b/src/components/customer/store-cart-card.tsx @@ -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 ( +
+ + {group.items.length.toLocaleString("fa-IR")} کالا + + + + + {group.store.name} + + +
+ {group.items.map((item) => ( + + ))} +
+ +
+ + جمع فروشگاه: {group.items_subtotal.toLocaleString("fa-IR")} تومان + +
+ + {children} +
+ ); +} diff --git a/src/components/product/product-purchase-panel.tsx b/src/components/product/product-purchase-panel.tsx index 5a8d00a..d7f431e 100644 --- a/src/components/product/product-purchase-panel.tsx +++ b/src/components/product/product-purchase-panel.tsx @@ -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). */}
+ + {product.is_out_of_stock ? "ناموجود" : "موجود"} + {!product.is_out_of_stock && } +
- - {toPersianDigits(quantity)} + {toPersianDigits(quantity)} +
- - {product.is_out_of_stock ? "ناموجود" : "موجود ●"} -
@@ -73,35 +81,35 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe {error &&

{error}

} {showToast && ( -
- +
+ محصول به سبد خرید اضافه شد - +
)} -
+
- -
+
جمع کل {(product.price * quantity).toLocaleString("fa-IR")} تومان
+
-
+
); diff --git a/src/components/profile/profile-exit-account.tsx b/src/components/profile/profile-exit-account.tsx new file mode 100644 index 0000000..a5fec14 --- /dev/null +++ b/src/components/profile/profile-exit-account.tsx @@ -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 ( +
+
+
+ +
+ خروج +
+ +
+ ); +}; + +export default ProfileExitAccount; \ No newline at end of file diff --git a/src/components/profile/profile-menu-item.tsx b/src/components/profile/profile-menu-item.tsx new file mode 100644 index 0000000..9196af6 --- /dev/null +++ b/src/components/profile/profile-menu-item.tsx @@ -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 ( + +
+
+ {icon || } +
+ {text} +
+ + + ); +}; + +export default ProfileMenuItem; \ No newline at end of file diff --git a/src/components/profile/profile-menu.tsx b/src/components/profile/profile-menu.tsx new file mode 100644 index 0000000..3f0843b --- /dev/null +++ b/src/components/profile/profile-menu.tsx @@ -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: , + }, + { + id: 2, + href: '/orders', + text: 'سفارش‌های من', + icon: , + }, + { + id: 3, + href: '/addresses', + text: 'آدرس‌های من', + icon: , + }, + { + id: 4, + href: '/profile/wallet', + text: 'کیف پول', + icon: , + }, + { + id: 5, + href: '/profile/notifications', + text: 'اعلان‌ها', + icon: , + }, + { + id: 6, + href: '/profile/support', + text: 'پشتیبانی', + icon: , + }, +]; + +const ProfileMenu = () => { + return ( +
+ {menuItems.map((item, index) => ( + + ))} +
+ +
+ ); +}; + +export default ProfileMenu; \ No newline at end of file diff --git a/src/components/profile/profile-page-head-line.tsx b/src/components/profile/profile-page-head-line.tsx new file mode 100644 index 0000000..cc4a40c --- /dev/null +++ b/src/components/profile/profile-page-head-line.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +interface ProfilePageHeadLineInterface { + title: String; +} + +const ProfilePageHeadLine = ({ title }: ProfilePageHeadLineInterface) => { + return

{title}

+} + +export default ProfilePageHeadLine \ No newline at end of file diff --git a/src/components/profile/profile-person-detail-card.tsx b/src/components/profile/profile-person-detail-card.tsx new file mode 100644 index 0000000..57d83d4 --- /dev/null +++ b/src/components/profile/profile-person-detail-card.tsx @@ -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 ( +
+

در حال بارگذاری...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!data) { + return ( +
+

اطلاعات پروفایل یافت نشد

+
+ ); + } + + const displayName = getDisplayNameHelper(data); + const initials = getInitialsHelper(data); + + return ( +
+
+
+ {data.avatar ? ( + {displayName} + ) : ( + {initials} + )} +
+
+

{displayName}

+ + {data.phone_number && ( +

+ {data.phone_number} +

+ )} + + +
+
+
+ ); +}; + +export default ProfilePersonDetailCard; \ No newline at end of file diff --git a/src/components/store/product-grid-card.tsx b/src/components/store/product-grid-card.tsx index defcdf8..e840bac 100644 --- a/src/components/store/product-grid-card.tsx +++ b/src/components/store/product-grid-card.tsx @@ -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 ( -
+
{error && (
{error}
)} -
+
{product.image_url && ( // eslint-disable-next-line @next/next/no-img-element - {product.name} + {product.name} )} -
+
-
-
- -

{product.name}

- -

- {product.price.toLocaleString("fa-IR")} تومان -

-
+
+ +

+ {product.name} +

+ +

+ {product.price.toLocaleString("fa-IR")} تومان +

+ {disabled && ( + + ناموجود + + )} +
- {disabled ? ( - ناموجود - ) : quantity === 0 ? ( - - ) : ( -
+ {!disabled && ( +
+
- {quantity.toLocaleString("fa-IR")} - + + {quantity > 0 && ( + <> + + {quantity.toLocaleString("fa-IR")} + + + + + )}
- )} -
+
+ )}
); } diff --git a/src/components/store/sticky-cart-bar.tsx b/src/components/store/sticky-cart-bar.tsx index c0744a8..523f79d 100644 --- a/src/components/store/sticky-cart-bar.tsx +++ b/src/components/store/sticky-cart-bar.tsx @@ -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 ( -
+
+ {/* 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. */}
- - - - - {itemCount.toLocaleString("fa-IR")} - - - مشاهده سبد - - -
+
جمع کل {total.toLocaleString("fa-IR")} تومان
+ + + مشاهده سبد + + {itemCount.toLocaleString("fa-IR")} + +
-
+
); } diff --git a/src/components/store/store-info-card.tsx b/src/components/store/store-info-card.tsx index 2f21db1..c07dc4c 100644 --- a/src/components/store/store-info-card.tsx +++ b/src/components/store/store-info-card.tsx @@ -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 ( -
-

{store.name}

-

{store.category.name}

+
+

+ {store.name} +

+

+ {store.category.name} +

- {today && ( - - {today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`} - - )} - | - + {store.is_open ? "فعال" : "بسته"} + | + {today && ( + + {today.is_closed + ? "تعطیل" + : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`} + + )}
-
+ @@ -56,7 +75,7 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) { {store.phone_number && ( diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 232d283..500f777 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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 + + +
+
+ ); +}; + +export default WalletBlancCard; \ No newline at end of file diff --git a/src/lib/api/account.ts b/src/lib/api/account.ts new file mode 100644 index 0000000..0ec2102 --- /dev/null +++ b/src/lib/api/account.ts @@ -0,0 +1,8 @@ +"use server"; + +import { gooyalFetch } from "./gooyal-accounts"; +import type { Account } from "@/types/api"; + +export async function getAccount(): Promise { + return gooyalFetch("/users/api/account/"); +} \ No newline at end of file diff --git a/src/lib/api/dev-logger.ts b/src/lib/api/dev-logger.ts index b7c3b49..43efe6c 100644 --- a/src/lib/api/dev-logger.ts +++ b/src/lib/api/dev-logger.ts @@ -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))}`); } diff --git a/src/lib/api/gooyal-accounts.ts b/src/lib/api/gooyal-accounts.ts new file mode 100644 index 0000000..92a0e16 --- /dev/null +++ b/src/lib/api/gooyal-accounts.ts @@ -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 { + auth?: boolean; + params?: Record; + body?: unknown; +} + +export async function gooyalFetch(path: string, options: GooyalFetchOptions = {}): Promise { + 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; +} \ No newline at end of file diff --git a/src/lib/api/winofy.ts b/src/lib/api/winofy.ts index 87487a6..a26e044 100644 --- a/src/lib/api/winofy.ts +++ b/src/lib/api/winofy.ts @@ -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(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(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; diff --git a/src/lib/auth/gooyal-client.ts b/src/lib/auth/gooyal-client.ts index 132c4a2..6b5e404 100644 --- a/src/lib/auth/gooyal-client.ts +++ b/src/lib/auth/gooyal-client.ts @@ -95,4 +95,4 @@ export async function exchangeOtpForToken(phoneNumber: string, otp: string) { throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description); } return data as GooyalTokenResponse; -} +} \ No newline at end of file diff --git a/src/lib/auth/gooyal.ts b/src/lib/auth/gooyal.ts index 277548b..5814252 100644 --- a/src/lib/auth/gooyal.ts +++ b/src/lib/auth/gooyal.ts @@ -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 { + 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: "" }); + + const res = await fetch(url, { method: "POST", headers: { Authorization: basicAuthHeader(), @@ -48,6 +54,12 @@ export async function refreshAccessToken(refreshToken: string): Promise ({})); + logApiResponse("gooyal", "POST", url, res.status, { + ...data, + access_token: data.access_token ? "" : undefined, + refresh_token: data.refresh_token ? "" : undefined, + }); + if (!res.ok) { throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description); } diff --git a/src/lib/providers/account-provider.tsx b/src/lib/providers/account-provider.tsx new file mode 100644 index 0000000..fe4ba14 --- /dev/null +++ b/src/lib/providers/account-provider.tsx @@ -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(undefined); + +export function AccountProvider({ + children, + account, + error = null, +}: { + children: ReactNode; + account: Account | null; + error?: string | null; +}) { + return ( + + {children} + + ); +} + +export function useAccount() { + const ctx = useContext(AccountContext); + if (ctx === undefined) { + throw new Error("useAccount must be used within AccountProvider"); + } + return ctx; +} \ No newline at end of file diff --git a/src/lib/utils/account-utils.ts b/src/lib/utils/account-utils.ts new file mode 100644 index 0000000..fbd53e9 --- /dev/null +++ b/src/lib/utils/account-utils.ts @@ -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 || 'کاربر'; +} \ No newline at end of file diff --git a/src/lib/utils/priceUtils.ts b/src/lib/utils/priceUtils.ts new file mode 100644 index 0000000..1ce20ff --- /dev/null +++ b/src/lib/utils/priceUtils.ts @@ -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)} تومان`; +}; \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts index c0c86e3..2945024 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; +} \ No newline at end of file