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