# 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 ```