KwadMarket Docs
Conventions

Frontend

Conventions for apps/web — structure, data, forms, UI, auth, types, tests

Read this before building or changing any web feature. These patterns are all live in the codebase — copy an existing feature (features/deals is the reference) rather than inventing.

Structure

  • Domain code lives in features/<domain>/ with: api.ts (endpoint strings + typed calls), queries.ts (useQuery hooks), mutations.ts (useMutation hooks), schemas.ts (zod), constants.ts (status configs, labels), components/, hooks/, types.ts (feature-local types only).
  • components/shared/ and components/layout/ are for cross-feature code only. hooks/ at root: only use-auth, use-chat, use-debounced-value.
  • Pages (app/**/page.tsx) compose feature components; they don't implement. Target ≤ ~150 lines per page file, ≤ ~250 per component file. Named exports, one main component per file, props interface <Component>Props above the component.

Data

The rule that regressed twice

Never fetch in useEffect. No manual loading flags. Both admin review queues regressed to effect-fetching — hold the line.

  • Server components fetch via lib/api/server.ts (serverFetch + the cached helpers). Public data gets revalidate; authed data is automatically no-store.
  • Client components use hooks from features/*/queries.ts / mutations.ts; loading state is isPending/isFetching.
  • Every endpoint string exists in exactly one features/*/api.ts. Client transport is lib/api/http.ts (goes through the /api/proxy rewrite so the HttpOnly cookie flows).
  • Query keys come from qk in lib/api/query-keys.ts — never inline ['scraper', 'products'] arrays. If a key is missing, add it to qk first. Mutations invalidate via qk.*, not string literals.
  • Polling: React Query refetchInterval returning false when done (see use-scrape-job.ts). Never setInterval/sleep loops in components; any long-running loop needs an unmount guard.
  • Realtime: the chat socket lives in ChatProvider (hooks/use-chat.tsx), unread counts are socket-pushed — don't add polling for anything the socket already announces.

Forms

  • react-hook-form + zod resolver, schema in features/*/schemas.ts. No multi-useState forms.
  • Field-level errors from the resolver; submit state from formState.isSubmitting or the mutation's isPending.

UI

  • Design tokens only: text-success, bg-destructive/10, text-warning, text-muted-foreground… Never raw palette (text-amber-600, bg-red-50) or literals (hsl(...), hex) in className. CI greps for literals; treat raw palette classes the same.
  • Reuse components/shared/ (Pagination, ImageLightbox, ImageUpload, useConfirm/ConfirmDialog, RelativeDate, HeroSection) and @marketplace/ui primitives. Extend with a prop instead of re-implementing. Never call browser confirm() — use useConfirm().
  • Status badges read their config from the feature's constants.ts — one source of truth per status enum. Don't create a second component for the same enum (this regressed once with DealStatusPill).
  • Icons: lucide-react only. Prices: formatPrice from lib/format.ts — no inline Number(x).toFixed(0). Dates: RelativeDate.
  • Images: next/image with sizes; never unoptimized; never <img>.

Auth

  • middleware.ts is the source of truth for route protection (prefix vs exact matching, admin gate). New protected routes go there, plus a case in middleware.test.ts.
  • Auth cookie is HttpOnly, set only by the app/api/auth/* route handlers. Client JS never touches the token.
  • JWT_SECRET is a required env var of the web app in production (middleware throws at boot without it). COOKIE_DOMAIN is required when web and API are on sibling subdomains.

Types & errors

  • No new any (@typescript-eslint/no-explicit-any is an error). API types come from @marketplace/types; feature-local types in features/*/types.ts.
  • No swallowed errors (catch(() => {})). Mutation failures surface via onError + toast. Route-level failures are handled by the error.tsx boundaries — don't add per-component try/catch UI.

Tests

  • Pure logic (schemas, utils, builders) gets a colocated *.test.ts. Stateful hooks/components with real logic get a Testing Library test.
  • e2e specs in e2e/ must use selectors scoped enough to survive unrelated UI additions (a global getByPlaceholder(/search/i) broke when header search shipped).
  • Anything touching queries/mutations should get an MSW-backed test once the MSW harness exists (see plan §frontend).

Verification gates

pnpm --filter @marketplace/web lint && pnpm --filter @marketplace/web exec tsc --noEmit
pnpm --filter @marketplace/web test
pnpm --filter @marketplace/web build     # hermetic: falls back to empty sections if the API is unreachable

Greps that must be clean in files you touched:

grep -n "useEffect" <files> | grep -iE "api\.|fetch"     # no effect-fetching
grep -n "catch(() => {})" <files>
grep -nE ": any|as any" <files>
grep -n "unoptimized" <files>
grep -nE "(text|bg|border)-(amber|green|red|blue|yellow)-[0-9]" <files>   # tokens, not raw palette

On this page