From d7a1067658786f1f31623596a4ca6f5fe1f25340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 29 Aug 2026 11:35:57 +0330 Subject: [PATCH] feat: documentation added --- FRONTEND_GUIDE.md | 215 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 FRONTEND_GUIDE.md 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 +```