555 lines
34 KiB
Markdown
555 lines
34 KiB
Markdown
# Balinyaar Client — Claude Code Guidelines
|
|
|
|
The web frontend of **Balinyaar**, a trust-first home-nursing marketplace in Iran. This file is the
|
|
**engineering contract** for everything under `client/`: providers, routing, data fetching, theming,
|
|
i18n, cookies, and the rules every change must follow.
|
|
|
|
- Repo-wide context and the backend → root [CLAUDE.md](../CLAUDE.md).
|
|
- Product/domain rules (what to build) → [`product/`](../product/) — read the relevant doc before
|
|
designing a feature; don't infer business rules from code.
|
|
- Visual/design work (brand palette, tokens, component look-and-feel) → the **frontend-designer**
|
|
skill. It is the *design* contract and defers to this file for *engineering* rules. Don't restate
|
|
this file there.
|
|
|
|
## Stack
|
|
|
|
- **Next.js 16** — App Router, Turbopack, React Server Components. **Not a static export** — the app
|
|
relies on server components, middleware, and server-side cookies. (`next.config.mjs` only wires the
|
|
next-intl plugin + `reactStrictMode`.)
|
|
- **React 19** + **TypeScript** (`strict`).
|
|
- **MUI v9** (`@mui/material`) for components and theming; **Emotion** underneath (RTL via
|
|
`stylis-plugin-rtl`).
|
|
- **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`.
|
|
- **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer,
|
|
`src/context/auth/`, seeded with server-read auth state) for auth/session state.
|
|
- **notistack** for toasts; **js-cookie** (wrapped) for client cookies.
|
|
- **Jest** + **Testing Library** for unit tests.
|
|
- Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**.
|
|
|
|
## Commands
|
|
|
|
| Task | Command |
|
|
| --- | --- |
|
|
| Dev server | `npm run dev` |
|
|
| Production build | `npm run build` |
|
|
| Type-check | `npm run type` |
|
|
| Lint | `npm run lint` |
|
|
| Lint + autofix | `npm run lint:fix` |
|
|
| **Type + lint (the gate)** | `npm run check` |
|
|
| Format (Prettier) | `npm run format` |
|
|
| Test (watch) | `npm test` |
|
|
| Test (CI, once) | `npm run test:ci` |
|
|
|
|
**Always run `npm run check` before declaring work done.** Run `npm run test:ci` as well when you
|
|
touch a component that has a co-located `*.test.tsx`.
|
|
|
|
## Quality gates: lint & type (how they work)
|
|
|
|
Both gates are plain CLI tools. **There is no `next lint`** — it was removed in Next 16; calling it
|
|
silently does nothing.
|
|
|
|
- `npm run type` → `tsc --noEmit`. Config in `tsconfig.json`: `strict` on, `noEmit`, `@/*` → `src/*`.
|
|
- `npm run lint` → `eslint .` driven by **flat config** in `eslint.config.mjs`. That config spreads
|
|
`eslint-config-next` (core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and
|
|
applies `eslint-config-prettier` last so ESLint never fights Prettier on formatting.
|
|
- `npm run check` runs type then lint. Keep it green.
|
|
|
|
Rules for this project:
|
|
- **This project is flat-config only.** Do not add `.eslintrc*` files — put any rule changes in
|
|
`eslint.config.mjs`.
|
|
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
|
|
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
|
|
eslint-config-next's default `warn` to **`error`** (in `eslint.config.mjs`), so dead code fails
|
|
`npm run check`. Delete unused code rather than disabling the rule; prefix a deliberately-unused
|
|
binding with `_` (e.g. `_event`, `catch (_err)`) to opt out.
|
|
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — e.g. a
|
|
deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
|
|
scoped `// eslint-disable-next-line <rule>` with a one-line reason, never a file-wide disable.
|
|
- **Pin to ESLint 9.** ESLint 10 currently crashes with this Next 16 toolchain
|
|
(`scopeManager.addGlobals is not a function`). `import/no-cycle` is also disabled — its TS resolver
|
|
has an interface mismatch here (see the note in `eslint.config.mjs`).
|
|
|
|
## Golden rules (the short list)
|
|
|
|
A change is "done" only if it respects all of these — each has a full section below.
|
|
|
|
1. **Never add a layout above `[locale]`.** `src/app/[locale]/layout.tsx` is the root layout (it
|
|
renders `<html>`/`<body>`). A layout above it freezes `lang`/`dir`/messages on the default locale.
|
|
2. **Respect the server/client boundary.** Never import `next/headers`, `next-intl/server`, or
|
|
`@/lib/cookies/server` from a client component; never import `@/lib/cookies/client` from an RSC.
|
|
3. **No hard-coded UI strings.** Every user-visible string is a key in **both** `messages/en.json`
|
|
and `messages/fa.json`.
|
|
4. **Fetch only through `clientFetch`/`serverFetch`** (`@/lib/api`) — never raw `fetch()`. Domain
|
|
calls live in `src/services/{domain}/apis/`.
|
|
5. **Cookies only through the cookie manager** (`@/lib/cookies/*`) — never `document.cookie`,
|
|
`js-cookie`, `localStorage`, or `sessionStorage` for app/auth state.
|
|
6. **Colors come from `tokens.css`** (`var(--…)`), never hard-coded in `sx`. Use the pre-built
|
|
`APP_THEME_LTR`/`APP_THEME_RTL`; never call `createTheme()` in a component.
|
|
7. **MUI v9 API only.** Use `sx={{ mb: 4 }}`, not `mb={4}` as a direct prop. No MUI-v5/v6-only props
|
|
(`useFlexGap`, `flexWrap` on `Stack`, `storageWindow`, `InitColorSchemeScript`, …).
|
|
8. **Shared components get a co-located `*.test.tsx`.** (A component imported from >1 place.)
|
|
9. **Magic strings become named constants** (`src/constants/` or a co-located `constants.ts`).
|
|
10. **`npm run check` is green** and translations stay in sync before you finish.
|
|
11. **No dead code; comment the *why*, not the *what*.** Unused vars/imports are lint errors — remove
|
|
them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or
|
|
trade-off. See **Comments & dead code** below.
|
|
|
|
## Project Structure
|
|
|
|
**This section is the canonical description of the client's architecture.** When a change adds, removes,
|
|
or renames a route group, provider, or top-level `src/` folder, update this tree in the same change
|
|
(root `CLAUDE.md` working agreement #7).
|
|
|
|
```
|
|
client/
|
|
├── messages/ # Translation files (add keys to BOTH files)
|
|
│ ├── en.json
|
|
│ └── fa.json
|
|
├── middleware.ts # next-intl routing middleware (locale detection + redirect)
|
|
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
|
|
└── src/
|
|
├── app/
|
|
│ ├── globals.css
|
|
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
|
|
│ └── [locale]/
|
|
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState)
|
|
│ ├── (private-routes)/
|
|
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout
|
|
│ │ └── page.tsx
|
|
│ └── (public-routes)/
|
|
│ └── layout.tsx # 'use client' — wraps PublicLayout
|
|
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
|
|
├── i18n/
|
|
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
|
|
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
|
|
├── layout/
|
|
│ ├── PrivateLayout.tsx # 'use client' — authenticated shell; uses useTranslations('nav')
|
|
│ ├── PublicLayout.tsx # unauthenticated shell
|
|
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition
|
|
│ ├── config.ts
|
|
│ ├── index.ts
|
|
│ └── components/
|
|
│ ├── TopBar.tsx
|
|
│ ├── SideBar.tsx
|
|
│ ├── SideBarNavList.tsx
|
|
│ ├── SideBarNavItem.tsx
|
|
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
|
|
│ └── index.tsx
|
|
├── lib/
|
|
│ ├── api/
|
|
│ │ ├── client.ts # clientFetch<T> — throws ApiError on error; use in hooks/client components
|
|
│ │ ├── server.ts # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
|
|
│ │ └── errors.ts # ApiError class (status, message, code)
|
|
│ ├── auth/
|
|
│ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers)
|
|
│ │ └── server.ts # getServerAuthState — access-token cookie → AuthState for AuthProvider
|
|
│ ├── query/
|
|
│ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton
|
|
│ │ └── QueryProvider.tsx # 'use client' — QueryClientProvider + ReactQueryDevtools
|
|
│ └── cookies/ # Cookie manager — strict server/client separation
|
|
│ ├── constants.ts # COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS
|
|
│ ├── server.ts # getServerCookie, getThemeMode, setServerCookie
|
|
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
|
|
│ └── index.ts # Re-exports constants ONLY (never server/client)
|
|
├── services/ # Domain services — no top-level barrel; import directly from the file
|
|
│ └── {domain}/
|
|
│ ├── types.ts # Request/response types for this domain
|
|
│ ├── keys.ts # React Query key factory
|
|
│ ├── apis/
|
|
│ │ ├── clientApi.ts # Namespace object wrapping clientFetch calls
|
|
│ │ └── serverApi.ts # Namespace object wrapping serverFetch calls (only when needed)
|
|
│ └── hooks/
|
|
│ └── use{Action}.ts # One hook per file — useQuery or useMutation
|
|
├── context/ # React context providers
|
|
│ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
|
|
├── theme/
|
|
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeScript + ColorSchemeCookieSync
|
|
│ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE
|
|
│ ├── light.ts / dark.ts # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts)
|
|
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
|
|
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
|
|
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
|
|
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
|
|
│ └── index.ts # Public re-exports (incl. ColorSchemeScript, ThemeProvider, getDirection)
|
|
├── constants/ # App-wide constants (routes, events, etc.)
|
|
├── hooks/
|
|
├── utils/
|
|
└── config.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Server / Client Component Boundaries
|
|
|
|
**There is NO `src/app/layout.tsx`.** `src/app/[locale]/layout.tsx` is the application's **root layout** — it renders `<html>` and `<body>`. This is intentional and load-bearing (see below); do not re-introduce a layout above the `[locale]` segment.
|
|
|
|
**Root / locale layout** (`src/app/[locale]/layout.tsx`) is an RSC that owns the document shell, all i18n, and theme context. It:
|
|
- Sources the locale from the **URL param** (`params.locale`), validated against `routing.locales` (falls back to `defaultLocale`). No header reads.
|
|
- Renders `<html lang dir>` (`dir` from `getDirection(locale)`) plus `data-mui-color-scheme` from `getThemeMode()`.
|
|
- Loads the Mikhak font and attaches its CSS-variable class to `<html>` **only for `fa`** (see Fonts).
|
|
- Calls `setRequestLocale(locale)` so server components deeper in the tree can call `getLocale()` / `getTranslations()` reliably.
|
|
- Calls `getMessages({ locale })` with the locale passed **explicitly** so `getRequestConfig` receives it via `Promise.resolve(locale)` (not through the React.cache read), avoiding any cache-ordering race.
|
|
- Wraps children with `NextIntlClientProvider`, `AuthProvider` (seeded with server-read auth state), and `ThemeProvider`.
|
|
- Exports `generateStaticParams` so Next.js can enumerate locale routes at build time.
|
|
|
|
**WHY `<html>` MUST live in `[locale]/layout.tsx` and not a layout above it**: a layout above the `[locale]` segment is *shared* between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` ('fa') and never re-renders it on a client-side locale switch (the segment doesn't change). Its `lang`/`dir`/messages therefore freeze on 'fa'/'rtl' for every route, including `/en`. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place where `<html lang dir>` reliably tracks the active locale.
|
|
|
|
**Route-group layouts** (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they only wrap a layout component and need no server capabilities.
|
|
|
|
**Never** import from `next/headers`, `next-intl/server`, or `@/lib/cookies/server` in a client component. The build will fail.
|
|
|
|
---
|
|
|
|
## i18n (next-intl v4)
|
|
|
|
**Adding translations:**
|
|
1. Add the key to `messages/en.json` AND `messages/fa.json`. Both files must always be in sync.
|
|
2. Top-level keys are namespaces: `"nav"`, `"common"`, etc.
|
|
|
|
**Using translations in client components:**
|
|
```tsx
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
function MyComponent() {
|
|
const t = useTranslations('nav'); // namespace
|
|
return <span>{t('home')}</span>; // key
|
|
}
|
|
```
|
|
|
|
**Using translations in Server Components:**
|
|
```tsx
|
|
import { getTranslations } from 'next-intl/server';
|
|
|
|
async function MyServerComponent() {
|
|
const t = await getTranslations('nav');
|
|
return <span>{t('home')}</span>;
|
|
}
|
|
```
|
|
|
|
**Established namespaces and where they're used:**
|
|
- `'nav'` — `PrivateLayout.tsx` (sidebar nav items)
|
|
- `'common'` — `DarkModeButton.tsx` (dark/light mode labels)
|
|
|
|
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
|
|
|
|
---
|
|
|
|
## Cookie Manager
|
|
|
|
The cookie manager in `src/lib/cookies/` is split into three files to prevent cross-environment bundling:
|
|
|
|
| File | Use from | Purpose |
|
|
|------|----------|---------|
|
|
| `constants.ts` | anywhere | `COOKIE_NAMES`, `CookieOptions`, `COLOR_SCHEME_COOKIE_OPTIONS` |
|
|
| `server.ts` | Server Components, Server Actions, Route Handlers only | `getServerCookie`, `getThemeMode`, `setServerCookie` |
|
|
| `client.ts` | client components / `useEffect` only | `getClientCookie`, `setClientCookie`, `deleteClientCookie` |
|
|
| `index.ts` | anywhere | Re-exports `constants.ts` only — safe barrel |
|
|
|
|
**Rules:**
|
|
- Import constants via the barrel: `import { COOKIE_NAMES } from '@/lib/cookies'`
|
|
- Import server utils directly: `import { getThemeMode } from '@/lib/cookies/server'`
|
|
- Import client utils directly: `import { setClientCookie } from '@/lib/cookies/client'`
|
|
- Never import `server.ts` in a client component; never import `client.ts` in an RSC.
|
|
- `COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` — the single source of truth for the theme cookie name. Do not redeclare it anywhere.
|
|
|
|
---
|
|
|
|
## Constants
|
|
|
|
**Rule: every magic string or configurable value must be a named constant — never inline.**
|
|
|
|
A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs.
|
|
|
|
Where to define:
|
|
- **Cookie names / options**: `src/lib/cookies/constants.ts`
|
|
- **Feature-scope constants**: co-locate in a `constants.ts` next to that feature's files
|
|
- **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.)
|
|
|
|
Rules:
|
|
1. Import the constant; never copy-paste the string value.
|
|
2. When renaming, update the constant definition — the rest of the codebase follows automatically.
|
|
|
|
---
|
|
|
|
## Theme System
|
|
|
|
### How it works (end-to-end, no-flash)
|
|
|
|
1. **Request arrives** → `getThemeMode()` reads `'color-scheme'` cookie → returns `{ colorScheme, defaultMode }`
|
|
2. **Root layout** sets `data-mui-color-scheme={colorScheme}` on `<html>` server-side
|
|
3. **`<ColorSchemeScript />`** in `<head>` runs before any paint:
|
|
- Reads the same cookie, sets `data-mui-color-scheme` (handles edge cases where server attr might differ)
|
|
- Patches `Storage.prototype` — routes MUI's `localStorage` writes for key `'mode'` to our cookie; reads return `null` so MUI always trusts the `defaultMode` prop
|
|
4. **`<MuiThemeProvider defaultMode={defaultMode}>`** mounts — uses the server-derived mode, not localStorage
|
|
5. **`ColorSchemeCookieSync`** in ThemeProvider writes the cookie via `useColorScheme().colorScheme` on mount (safety net for first-visit system mode)
|
|
|
|
### Critical MUI v9 rules
|
|
|
|
**`colorSchemeSelector` must be the explicit attribute name:**
|
|
```ts
|
|
// theme.ts
|
|
cssVariables: {
|
|
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
|
|
// colorSchemeSelector: 'data', // WRONG — produces boolean data-dark/data-light
|
|
},
|
|
```
|
|
The shorthand `'data'` in MUI v9 generates `[data-%s]` → `data-dark=""` / `data-light=""` (boolean attributes). Our `tokens.css` uses `[data-mui-color-scheme="dark"]` which never matches boolean attributes. Always use the explicit attribute name.
|
|
|
|
**Never use `storageWindow={null}`:**
|
|
In MUI v9's `localStorageManager`, the check is `if (!storageWindow && typeof window !== 'undefined')` — `null` is falsy, so it silently overrides to `window`. This prop is a no-op in browsers. The `Storage.prototype` patch in `ColorSchemeScript` is the correct intercept.
|
|
|
|
**Never use MUI's `InitColorSchemeScript`:**
|
|
It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use `ColorSchemeScript` from `@/theme` instead.
|
|
|
|
**MUI v9 localStorage key defaults (different from v5/v6):**
|
|
- Mode key: `'mode'` (was `'mui-mode'`)
|
|
- Color scheme key: `'color-scheme'` (was `'mui-color-scheme'`)
|
|
- HTML attribute: `'data-color-scheme'` (was `'data-mui-color-scheme'`)
|
|
|
|
We override all of these via `colorSchemeSelector: 'data-mui-color-scheme'` in the theme and the Storage.prototype patch.
|
|
|
|
### Color tokens
|
|
|
|
All theme-aware colors live in `src/theme/tokens.css` under `[data-mui-color-scheme]` selectors. Do not add color values to inline `sx` props or component styles — add a CSS variable to `tokens.css` and reference it via `var(--my-token)`.
|
|
|
|
This includes feedback colors: `--bal-success`, `--bal-error`, `--bal-warning`, `--bal-info` (each with a `*-contrast` text token). These drive the toast variants (see Toast Notifications) and are the place to source any success/error/warning/info color — the MUI palette does **not** define semantic colors, so prefer these tokens over MUI's defaults for brand consistency.
|
|
|
|
### Pre-built theme objects
|
|
|
|
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. Never call `createTheme()` inside a component or hook — pass the appropriate pre-built theme to `MuiThemeProvider`.
|
|
|
|
### Toggle components
|
|
|
|
`DarkModeToggleButton` and `DarkModeFormSwitch` in `src/layout/components/DarkModeButton.tsx` are the **only** components that subscribe to `useColorScheme()`. When the user toggles:
|
|
1. `setMode('dark')` is called
|
|
2. `Storage.prototype.setItem` intercept fires → writes `'color-scheme'='dark'` cookie synchronously
|
|
3. MUI sets `data-mui-color-scheme="dark"` on `<html>`
|
|
4. CSS variables resolve → browser repaints. No React re-render above the button.
|
|
|
|
Use `colorScheme` (not `mode`) for the `isDark` check — `mode` can be `'system'` even when dark is active.
|
|
|
|
---
|
|
|
|
## Direction (RTL / LTR)
|
|
|
|
Derived from locale via `getDirection(locale)` in `src/theme/direction.ts`:
|
|
- RTL locales: `fa`, `ar`, `he`, `ur`
|
|
- All others: `ltr`
|
|
|
|
`ThemeProvider` accepts a `dir` prop and selects the matching pre-built theme (`APP_THEME_RTL` for RTL). The RTL Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
|
|
|
|
`src/app/[locale]/layout.tsx` sets `dir={dir}` on `<html>` and passes `dir` to `ThemeProvider`. Because that layout is keyed on the `[locale]` URL param, changing locale re-renders it with a fresh `dir` — on both hard and soft navigation, no client-side state. **Do not** move the `<html dir>` render to a layout above `[locale]`; such a layout is shared across locales, gets statically cached with the default locale, and `dir` freezes on 'rtl' for `/en`.
|
|
|
|
**Default locale is `fa` (RTL).** The middleware redirects bare `/` to `/fa/`. English is explicitly accessed at `/en/`.
|
|
|
|
---
|
|
|
|
## Fonts
|
|
|
|
Fonts are loaded **per locale** — the Persian face is never shipped to English pages:
|
|
|
|
| Locale | Font | CSS variable | Source | Loaded when |
|
|
|--------|------|--------------|--------|-------------|
|
|
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` | only on `fa` routes |
|
|
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | (not currently wired — falls back to the system stack) | — |
|
|
|
|
**Typography exports:**
|
|
- `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`)
|
|
- `TYPOGRAPHY_RTL` — Mikhak for all text including body (used by `APP_THEME_RTL`, ensures full Persian glyph coverage)
|
|
- `TYPOGRAPHY` — alias for `TYPOGRAPHY_LTR` (deprecated, prefer the explicit exports)
|
|
|
|
**Rules:**
|
|
- Mikhak is declared with `preload: false`, and its `.variable` class is attached to `<html>` **only when `locale === 'fa'`**. Both are required: a `next/font` loader called in the root layout would otherwise preload on every route (including `/en`), and `preload: false` ensures the woff2 only downloads when Persian text actually renders.
|
|
- Font files live in `src/app/fonts/` (not `public/`). next/font/local resolves paths relative to the calling file (`src/app/[locale]/layout.tsx`) at build time.
|
|
- Never load fonts inside components — all font loading lives in `src/app/[locale]/layout.tsx`.
|
|
- To add a new font, add woff2 files to `src/app/fonts/`, declare via `localFont`/`localFont`-equivalent in `src/app/[locale]/layout.tsx`, attach its `.variable` class conditionally on the matching locale, and update `BRAND_FONT_VARIABLE_*` constants in `typography.ts`.
|
|
|
|
---
|
|
|
|
## Unit Testing
|
|
|
|
**Rule: every shared component must have a co-located test file.**
|
|
|
|
A component is "shared" if it is imported from more than one place (page, layout, or other component).
|
|
|
|
Coverage baseline for shared components:
|
|
1. It renders without crashing.
|
|
2. Every documented prop produces the correct HTML attribute or CSS class.
|
|
3. User interactions (click, change) call the expected callbacks.
|
|
|
|
Test location: `src/components/ComponentName/ComponentName.test.tsx` next to the component.
|
|
Test wrapper: wrap with `<ThemeProvider>` if the component uses MUI theming.
|
|
Do NOT mock MUI components — test against the rendered DOM.
|
|
|
|
Enforcement: before removing or renaming a shared component, check whether `src/**/*.test.{ts,tsx}` files import it. If so, update or delete those tests too.
|
|
|
|
---
|
|
|
|
## Comments & dead code
|
|
|
|
- **No dead code.** Unused variables, imports, parameters, and private members are lint errors
|
|
(`@typescript-eslint/no-unused-vars`, raised to `error` — see *Quality gates*). Delete them; don't
|
|
comment them out and don't silence the rule. Prefix a deliberately-unused binding with `_` to opt out.
|
|
- **Comment the *why*, never the *what*.** Code should read for itself — a comment that restates what
|
|
the code already says is noise. Don't write `// set the access token` above `setClientCookie(...)`, or
|
|
JSDoc that just echoes a function's name.
|
|
- **Do** add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a
|
|
framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain
|
|
*why it is this way*. The comments in `src/app/[locale]/layout.tsx` (why `<html>` lives in the
|
|
`[locale]` layout) and `src/lib/auth/token.ts` (why the JWT `exp` check is UX-only, never a security
|
|
boundary) are the model to follow.
|
|
- Prefer a clearer name or a small helper over a comment whenever that removes the need for it.
|
|
|
|
## Anti-patterns (do not do these)
|
|
|
|
- **Do not** read `localStorage` or `document.cookie` in render functions — use `useEffect` or server-side `cookies()` from `next/headers`.
|
|
- **Do not** call `createTheme()` inside a component or hook — use `APP_THEME_LTR` / `APP_THEME_RTL`.
|
|
- **Do not** use `storageWindow={null}` on `MuiThemeProvider` — it is silently ignored in MUI v9.
|
|
- **Do not** use `InitColorSchemeScript` from MUI — use `ColorSchemeScript` from `@/theme`.
|
|
- **Do not** set `colorSchemeSelector: 'data'` — use `'data-mui-color-scheme'`.
|
|
- **Do not** check `mode === 'dark'` for "is dark active" — use `colorScheme === 'dark'`.
|
|
- **Do not** hard-code UI strings — add translation keys to both `messages/en.json` and `messages/fa.json`.
|
|
- **Do not** add a `src/app/layout.tsx` or any layout above the `[locale]` segment. Such a layout is shared across locales, gets statically cached at build time with `defaultLocale` ('fa'), and never re-renders on a locale switch — so `<html lang/dir>`, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for `/en`. `src/app/[locale]/layout.tsx` is the root layout (it renders `<html>`/`<body>`) precisely because it is the lowest boundary keyed on the locale param.
|
|
- **Do not** call `getMessages()` without passing `{ locale }` explicitly — `getMessages({ locale })` passes the locale directly to `getRequestConfig` via `Promise.resolve(locale)`, bypassing potential React.cache ordering issues.
|
|
- **Do not** remove `setRequestLocale(locale)` from `src/app/[locale]/layout.tsx` — without it, `getLocale()` called by deeper server components always returns `defaultLocale`.
|
|
- **Do not** add `notFound()` to `src/app/[locale]/layout.tsx` — unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior.
|
|
- **Do not** import `TYPOGRAPHY` — use `TYPOGRAPHY_LTR` or `TYPOGRAPHY_RTL` explicitly.
|
|
- **Do not** load fonts inside components or pages — all next/font declarations belong in `src/app/[locale]/layout.tsx`, with the `.variable` class attached conditionally per locale (Mikhak only for `fa`).
|
|
- **Do not** import `@/lib/cookies/server` in client components or `@/lib/cookies/client` in RSCs.
|
|
- **Do not** call `fetch()` directly in components or services — use `serverFetch` (RSC/Server Actions) or `clientFetch` (hooks/Client Components) from `@/lib/api`.
|
|
- **Do not** create a top-level barrel at `src/services/index.ts` — imports should make the domain origin clear (e.g. `import { useLogin } from '@/services/auth'`, not `import { useLogin } from '@/services'`).
|
|
- Each domain **does** have an `index.ts` that re-exports its hooks (e.g. `src/services/auth/index.ts`). Do not export `types`, `keys`, or `apis/*` from this barrel — only hooks.
|
|
- **Do not** mix `clientFetch` and `serverFetch` in the same file — keep `clientApi.ts` and `serverApi.ts` separate; Next.js enforces the environment boundary at build time.
|
|
- **Do not** toast inside hooks for 401/403/5xx — those are already toasted by `clientFetch`. Only toast in `onError` for domain-specific 4xx messages.
|
|
- **Do not** call `js-cookie` (`Cookies.*`) directly — use the central client cookie manager (`@/lib/cookies/client`).
|
|
- **Do not** read or write `document.cookie` directly — use the central client cookie manager.
|
|
- **Do not** store auth tokens in `sessionStorage` or `localStorage` — use cookies via `@/lib/cookies/client`.
|
|
- **Do not** pass `flexWrap` or `useFlexGap` as direct props to MUI `Stack` — these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}` instead. `useFlexGap` was a MUI v5 opt-in and does not exist in v9.
|
|
- **Do not** use mui old api which cause errors
|
|
---
|
|
|
|
## API Fetch Services
|
|
|
|
Central fetch primitives live in `src/lib/api/`:
|
|
|
|
| File | Use from | Purpose |
|
|
|------|----------|---------|
|
|
| `client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error |
|
|
| `server.ts` | RSCs, Server Actions only | `serverFetch<T>` — throws `ApiError` on error |
|
|
| `errors.ts` | anywhere | `ApiError` class (`status`, `message`, `code`) |
|
|
|
|
**Error contract — `clientFetch`:**
|
|
- **401** — toast "session expired", clear cookies, redirect to login (no throw; page navigates away)
|
|
- **403** — toast "forbidden", throw `ApiError`
|
|
- **5xx** — toast "server error", throw `ApiError`
|
|
- **Other 4xx** — throw `ApiError`, no toast; the calling hook owns the user-facing message
|
|
- **Network failure** — toast "network error", throw `ApiError`
|
|
|
|
**Error contract — `serverFetch`:**
|
|
- All errors throw `ApiError` (no toast — server can't fire browser events)
|
|
- RSC callers decide whether to `notFound()`, `redirect()`, or let the error propagate to an error boundary
|
|
|
|
**Domain API calls** live in `src/services/{domain}/apis/clientApi.ts` (or `serverApi.ts`). Never call raw `fetch()` directly.
|
|
|
|
---
|
|
|
|
## Auth Cookies & session state
|
|
|
|
| Cookie | Constant | TTL | Set by |
|
|
|--------|----------|-----|--------|
|
|
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `useLogin()` in `src/services/auth/hooks/useLogin.ts` |
|
|
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | `useLogin()` in `src/services/auth/hooks/useLogin.ts` |
|
|
|
|
**Session state lives in `AuthContext`** (`src/context/auth/`). The root layout resolves the session on
|
|
the server with `getServerAuthState()` (`src/lib/auth/server.ts`) — which reads the `access_token` cookie
|
|
and checks the JWT `exp` via the shared `isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to
|
|
`<AuthProvider initialState={…}>`. So the **first render already knows whether the user is
|
|
authenticated**: no logged-out flash, no post-mount cookie read.
|
|
|
|
**Lifecycle:**
|
|
- Written after a successful login via `setClientCookie` (`useLogin`), which also dispatches `LOG_IN` to
|
|
keep `AuthContext` in sync on the client without a reload.
|
|
- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) — the single logout path: it calls
|
|
the API, clears both cookies, dispatches `LOG_OUT`, and redirects — and automatically by `clientFetch`
|
|
on 401.
|
|
- Read on the server by `serverFetch` / `getServerAuthState` via `getServerCookie`.
|
|
- Read on the client by `clientFetch` via `getClientCookie` (to attach `Authorization: Bearer`).
|
|
|
|
**`useIsAuthenticated()`** (`src/hooks/auth.ts`) reads the server-seeded `AuthContext`, so it is correct
|
|
on the first paint (it no longer reads the cookie after mount).
|
|
|
|
**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render.
|
|
|
|
**Security posture — current limits and best-practice follow-ups.** The flow above is the intended
|
|
client design, but the auth model has known gaps that need *server* coordination to close. Don't
|
|
silently "fix" them client-only:
|
|
- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header — this
|
|
trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a
|
|
same-origin proxy) spans the server.
|
|
- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but
|
|
does **not** verify the signature. The API is the only authority; never gate real authorization on the
|
|
middleware or `isTokenAlive`.
|
|
- **No refresh-token rotation** is implemented — the `refresh_token` cookie is set/cleared but never
|
|
exchanged; access expiry just forces re-login on the next 401.
|
|
- **No role/permission model** yet (`User` is `{ id, username }`); authorization is binary. Add roles to
|
|
`User`/`AuthState` and the server before gating UI by permission.
|
|
|
|
---
|
|
|
|
## Toast Notifications (notistack)
|
|
|
|
`<SnackbarProvider>` wraps all children inside `ThemeProvider` in `src/app/[locale]/layout.tsx`.
|
|
|
|
**In React components/hooks** — use notistack directly:
|
|
```tsx
|
|
import { useSnackbar } from 'notistack'
|
|
const { enqueueSnackbar } = useSnackbar()
|
|
enqueueSnackbar('Saved!', { variant: 'success' })
|
|
```
|
|
|
|
**Outside React** (plain functions, fetch services) — use the event bridge:
|
|
```ts
|
|
import { dispatchToast } from '@/lib/toast'
|
|
dispatchToast('Something went wrong', 'error')
|
|
```
|
|
`dispatchToast` fires a `window` CustomEvent (`app:toast`). `ToastBridge` (a zero-UI `'use client'` component inside `SnackbarProvider`) listens and calls `enqueueSnackbar`.
|
|
|
|
`ToastBridge` is already rendered in `[locale]/layout.tsx` — do not add another instance.
|
|
|
|
**Toast colors follow the theme.** `NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose `backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `*-contrast`) tokens in `tokens.css`. Because those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme automatically. Never hard-code a toast color — adjust the tokens instead.
|
|
|
|
**Direction is inherited, not passed.** notistack's Portal mounts under `<body>`, so it inherits `dir` from `<html dir>` (set per-locale in the root layout). Do **not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary:
|
|
```tsx
|
|
<NotistackProvider>{children}</NotistackProvider>
|
|
```
|
|
|
|
---
|
|
|
|
## Route Constants
|
|
|
|
Named path constants live in `src/constants/routes.ts`:
|
|
```ts
|
|
ROUTES.LOGIN = '/login'
|
|
ROUTES.HOME = '/'
|
|
PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check
|
|
```
|
|
Import from the barrel: `import { ROUTES, PUBLIC_PATHS } from '@/constants'`.
|
|
|
|
To add a new public route, append it to `PUBLIC_PATHS` — the middleware picks it up automatically.
|
|
|
|
---
|
|
|
|
## Client Cookie Manager (js-cookie)
|
|
|
|
`src/lib/cookies/client.ts` uses `js-cookie` internally. The exported API is unchanged:
|
|
|
|
| Function | Purpose |
|
|
|----------|---------|
|
|
| `getClientCookie(name)` | Read a cookie by name |
|
|
| `setClientCookie(name, value, options?)` | Write a cookie; `options` is `CookieOptions` with `maxAge` in **seconds** |
|
|
| `deleteClientCookie(name, path?)` | Delete a cookie |
|
|
| `getColorSchemeCookie()` | Typed helper for the theme cookie |
|
|
|
|
`CookieOptions` type is defined in `src/lib/cookies/constants.ts` — `maxAge` is in seconds (converted to `expires: Date` internally when calling js-cookie).
|
|
- **Do not** `document.title = title` in the render body of any component — it causes `ReferenceError: document is not defined` during build-time prerendering.
|