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

Styling is vanilla CSS in co-located CSS Modules — not Tailwind. See Styling for the full model (tokens, cascade layers, motion, the cx() helper). Summary for day-to-day work:

  • Every component with non-trivial styling gets a sibling Component.module.css, imported as s (import s from './component.module.css') and applied as className={s.foo}.
  • Design tokens only: var(--c-success), hsl(var(--destructive) / 0.1), var(--text-sm)… Never raw palette (#f59e0b, rgb(34 197 94)) or ad-hoc pixel values where a token exists.
  • Conditional classes use cx() (from @marketplace/ui), not template-literal interpolation. Write each logical group as its own cx() argument: cx(s.base, isActive && s.active, className).
  • Reuse components/shared/ (Pagination, ImageLightbox, ImageUpload, useConfirm/ConfirmDialog, RelativeDate, HeroSection, HardCard) and @marketplace/ui primitives (Input, Textarea, Select, Switch, Button with its pill/pillOutline variants). Extend with a prop instead of re-implementing — never hand-roll a raw <input> or a bespoke pill <button>. A style repeated within one file → a local class; repeated across files → a primitive. Never call browser confirm() — use useConfirm().
  • Status badges read their config from the feature's constants.ts (a semantic tone, not raw colors) — one source of truth per status enum, rendered through the shared StatusBadge primitive. Don't create a second component for the same enum.
  • Icons: lucide-react only, sized via strokeWidth/the module's own class — never Tailwind bracket syntax. Prices: formatPrice from lib/format.ts. Dates: RelativeDate.
  • Images: next/image with sizes; never unoptimized; never <img>.
  • className on a @marketplace/ui primitive positions it in the caller's layout (margin, width, grid-area) — it must not restyle the primitive's interior (colors, padding, typography). If you need that, the primitive needs a variant or a token.

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
pnpm check:styles                        # Tailwind residue + CSS Modules co-location + stylelint (see styling.mdx)
pnpm format:check                        # prettier (apps/web, packages/ui)

check:styles and format:check also run in CI. The ratchet baselines in scripts/lint-styles.sh may only be lowered as violations are cleaned up — never raised.

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 "className=\"[^\"]*(#|rgb\(|rgba\(|hsla\()" <files>   # tokens, not raw palette/literals

On this page