winofy-front/FRONTEND_GUIDE.md
2026-08-29 11:35:57 +03:30

16 KiB

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.

git clone <repo>
cd winofy
npm install
cp .env.example .env.local   # fill in real values — ask a teammate for staging Gooyal credentials
npm run dev                  # http://localhost:3000

Scripts (package.json):

Script Runs
npm run dev next dev (Turbopack)
npm run build next build
npm run start next start (the standalone build)
npm run lint ESLint, flat config (eslint-config-next)

No test runner is configured yet.

4. Architecture

4.1 Route structure

App Router route groups split the app by audience and auth requirement:

  • (auth)/login — phone + OTP login (Gooyal)
  • (customer)/ — pages without the persistent shop header (home, location onboarding)
  • (customer)/(shop)/ — everything with the shop chrome: stores, products, cart, checkout, orders, addresses, profile
  • (seller)/seller — seller-side dashboard (early stage)
  • src/proxy.ts — Next 16's renamed middleware.ts (see §4.3)
  • src/app/api/* — same-origin route handlers, used only where the browser needs to call something itself (session probe, logout, the neighborhood-search proxy — see §4.6)

4.2 Winofy API client & error envelope

All calls to the Winofy API go through winofyFetch() in src/lib/api/winofy.ts. It:

  • prefixes WINOFY_API_BASE_URL, attaches query params, JSON-encodes the body
  • attaches Authorization: Bearer <accessToken> from the session, unless called with { auth: false }
  • always sets cache: "no-store" — nothing from this API is safe to cache past the current request
  • logs every request/response in dev via src/lib/api/dev-logger.ts (pretty-printed, truncated, color-coded by status) — this is your Network tab, since these calls never reach the browser
  • throws ApiError on non-2xx, parsed from the API's error envelope:
{
  "success": false,
  "status_code": 400,
  "status_message": "...",
  "details": { "message": "...", "error": "...", "timestamp": "..." }
}

details.message is either a plain string or a { field: string[] } map of validation errors — ApiError.fieldErrors normalizes that for forms.

Server Actions wrap winofyFetch calls in callApi() (src/lib/api/result.ts), which never throws — it returns a typed ActionResult<T> ({ ok: true, data } or { ok: false, status, message, fieldErrors }), so client components can branch on failure without try/catch.

4.3 Auth & sessions

Login is phone number + OTP against Gooyal, via the OAuth2 password grant with auth_fields=phone_number:otp. Two client modules exist because Gooyal is called from two different places:

  • src/lib/auth/gooyal-client.ts — browser-side ("use client"), used by the login/OTP form. Gooyal's CORS is open, so this calls accounts-staging.gooyal.ir directly — it does not proxy through our own API routes. Uses the NEXT_PUBLIC_* env vars (§8).
  • src/lib/auth/gooyal.ts — server-side, used only for refresh-token exchange. Uses the non-public env vars.

Once tokens come back, createSession() (src/lib/auth/session.ts) encrypts them into a JWE (jose, alg: dir, enc: A256GCM) and stores it as the winofy_session httpOnly cookie — a 30-day cookie lifetime, independent of the much shorter-lived Gooyal access token inside it.

getValidSession() is the actual source of truth for "is this user logged in": it decrypts the cookie, and if the access token is expired (30s skew), transparently refreshes it and re-persists the new session — deduped per-request via React's cache(). Prefer this over the raw getSession() everywhere.

src/proxy.ts (Next 16 renamed middleware.ts → proxy.ts; the export is now export default function proxy(request), not middleware) does an optimistic, cookie-presence-only check against AUTH_REQUIRED_PREFIXES and redirects to /login?next=... if the cookie is missing entirely. It deliberately does not decrypt or validate the session — that would mean an extra round-trip on every request. A present-but-expired-and-unrefreshable session still reaches the page; that's what requireSession(path) is for — call it at the top of any page under an auth-required prefix, before fetching data, so a dead session redirects cleanly instead of the page crashing on an uncaught 401.

Cookie writes (createSession/deleteSession) are wrapped in try/catch: Next.js forbids mutating cookies during a plain Server Component render (only Server Actions and Route Handlers may), but getValidSession() can legitimately be called from either. When called from a render, a refreshed token is still used for the rest of that request — it just silently fails to persist, and the next request repeats the refresh. That's intentional, not a bug to "fix" by moving the call elsewhere.

4.4 API layer organization

src/lib/api/ is organized by feature, one file per resource, all built on winofyFetch:

addresses.ts   cart.ts       catalog.ts        checkout.ts
locations.ts   media.ts      notifications.ts  orders.ts
reviews.ts     stores.ts     result.ts         errors.ts
winofy.ts      dev-logger.ts
seller/
  analytics.ts  orders.ts  products.ts  reviews.ts  store.ts

Mutations (add/update/remove/create/cancel...) are Server Actions, called directly from client components as props — no fetch/API-route indirection. Reads are plain async functions called from Server Components. There's no separate "actions" layer — an earlier version split reads/writes into src/lib/actions/, and it was folded into these feature files because the split added indirection without a real benefit.

4.5 Data fetching pattern

Almost everything renders server-side: pages are async function Server Components that call src/lib/api/* reads directly and pass plain data down as props. This is why requests are invisible in Chrome's Network tab — they never leave the Next.js server process. Two consequences worth knowing before you go looking for a bug in the wrong place:

  • To see these requests, read the dev logger output in your terminal (or next.config.ts's logging.fetches, which shows URL/status/timing only, no body).
  • Client-side navigation between pages (e.g. clicking a store card) can show up in the Network tab as just an RSC fetch (?_rsc=...). The store-detail data fetch still happened server-side to produce that payload — it's just bundled into that one request instead of a separate visible GET /stores/:id.

Cart/mutation UI (ProductGridCard, ProductPurchasePanel, etc.) is the exception: those are client components that call Server Actions via useTransition, update local state only after the action confirms success, then router.refresh() to reconcile with the true server state.

4.6 Location & neighborhoods

Location is stored client-side as a plain (unencrypted — not sensitive) cookie, winofy_location, via src/lib/location/{client,server,types}.ts. Two modes:

{ mode: "geo"; lat; lng }
{ mode: "manual"; cityUuid; cityName; neighborhoodUuid; neighborhoodName }

The home page ((customer)/page.tsx) reads it server-side to filter getStores(); the location picker (location/search) writes it client-side after a manual neighborhood pick or a successful geolocation read, and can clear it back to "no location selected."

Neighborhood search goes through a same-origin route handler, src/app/api/locations/neighborhoods/route.ts, rather than the client calling the Winofy API directly — necessary because the browser can't reach the non-CORS Winofy API itself. That handler also patches a real backend gap (see gap 1 below).

Known gaps in this area, numbered so future work can reference them:

  1. Backend does not implement server-side neighborhood search filtering — worked around client-of-the-route-handler-side against both name and city.name.
  2. The location cookie is 180 days — deliberate, so the L01 "share your location" onboarding screen doesn't refire every session.
  3. Geolocation errors are surfaced by GeolocationPositionError.code (PERMISSION_DENIED / POSITION_UNAVAILABLE / TIMEOUT) with distinct Persian copy per case. macOS's kCLErrorLocationUnknown surfaces here as POSITION_UNAVAILABLE — that's CoreLocation reporting "no fix yet," not a frontend bug.

5. Design system

  • RTL throughout: <html dir="rtl" lang="fa"> in src/app/layout.tsx. Write layout CSS RTL-first (text-right, logical properties) rather than retrofitting an LTR layout.
  • Font: Vazirmatn (next/font/google, arabic + latin subsets), exposed as the --font-vazirmatn CSS variable and mapped to Tailwind's font-sans.
  • All design tokens live in src/app/globals.css as plain hex custom properties under :root, re-exposed to Tailwind via @theme inline. Tailwind v4's built-in palette (emerald, slate, etc.) is OKLCH internally — close to, but not bit-for-bit, the hex values in the design spec — so every color that must match a spec exactly is defined as its own token here rather than borrowed from Tailwind's defaults. Adding a new spec color means adding a token here, not reaching for e.g. emerald-500 directly.
  • Persian digit handling: src/lib/format/persian-digits.ts (toPersianDigits / toLatinDigits) — needed anywhere user-facing numbers are displayed, or anywhere user-typed numbers (phone, OTP) are parsed, since Persian keyboards type Persian-Arabic digit glyphs that fail plain \d regexes.
  • Iranian week: Saturday is the first day. Convert from JS's Date.getDay() (Sunday = 0) with (jsDay + 1) % 7 wherever a weekday needs to be shown.

6. Docker & deployment

Multi-stage Dockerfile, output: "standalone" — the runtime image ships only server.js, .next/static, and public/, runs as a non-root user, listens on $PORT (default 3000).

The one thing not to get wrong: NEXT_PUBLIC_* vars are baked into the client bundle at build time. Everything else (API base URL, Gooyal secret, session secret) is read at container runtime instead, and must never be baked into an image layer.

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-args (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