From 655f56e841346d9d4a3ee6429f9d8fabcb0d85f0 Mon Sep 17 00:00:00 2001 From: Geoffrey DULAC Date: Wed, 1 Jul 2026 15:20:49 -0400 Subject: [PATCH 1/6] add Instructions --- .github/copilot-instructions.md | 66 +++ .../components-vs-containers.instructions.md | 210 +++++++++ .github/instructions/forms.instructions.md | 405 ++++++++++++++++ .github/instructions/i18n.instructions.md | 269 +++++++++++ .github/instructions/icons.instructions.md | 330 +++++++++++++ .github/instructions/infra.instructions.md | 435 ++++++++++++++++++ .../mui-and-uikit.instructions.md | 266 +++++++++++ .github/instructions/react.instructions.md | 347 ++++++++++++++ .../routing-and-auth.instructions.md | 390 ++++++++++++++++ .../instructions/services-api.instructions.md | 320 +++++++++++++ .../instructions/state-stores.instructions.md | 352 ++++++++++++++ .github/instructions/styling.instructions.md | 319 +++++++++++++ .../instructions/typescript.instructions.md | 278 +++++++++++ frontend/docs/ComponentsAndContainers.md | 20 - frontend/docs/ReactStandards.md | 222 --------- frontend/docs/Styling.md | 73 --- 16 files changed, 3987 insertions(+), 315 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/components-vs-containers.instructions.md create mode 100644 .github/instructions/forms.instructions.md create mode 100644 .github/instructions/i18n.instructions.md create mode 100644 .github/instructions/icons.instructions.md create mode 100644 .github/instructions/infra.instructions.md create mode 100644 .github/instructions/mui-and-uikit.instructions.md create mode 100644 .github/instructions/react.instructions.md create mode 100644 .github/instructions/routing-and-auth.instructions.md create mode 100644 .github/instructions/services-api.instructions.md create mode 100644 .github/instructions/state-stores.instructions.md create mode 100644 .github/instructions/styling.instructions.md create mode 100644 .github/instructions/typescript.instructions.md delete mode 100644 frontend/docs/ComponentsAndContainers.md delete mode 100644 frontend/docs/ReactStandards.md delete mode 100644 frontend/docs/Styling.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..bd5ec8e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,66 @@ +# web-react-skeleton — Copilot instructions + +Vite + React 18 + TypeScript SPA, styled with MUI v6 (`@mui/material` + `@mui/material-pigment-css`), routed by React Router v6, state via Zustand, forms via Yup, i18n via i18next (locales generated from a Google Sheet). Deployed as static files to Azure Storage `$web` + Azure CDN. Package manager: **Yarn 4** (`packageManager: yarn@4.5.0`). Node: **22.x** in CI, `20.11.1` in the local Docker dev container. + +This file is an orientation + index. **It does not restate any rule** — every convention lives in a scoped `.instructions.md` file (see the table below). If a rule seems missing here, load the matching scoped file. + +## Repo layout + +``` +frontend/ React app (Vite, all source, all rules) +terraform/ HCL infra (per-env tfvars in env/) +azure-pipeline/ Azure Pipelines templates (root: azure-pipelines.yml) +doc/ Human docs (Azure setup notes) +.github/instructions/ <-- scoped instruction files (this table) +``` + +Working directory for every yarn command is `frontend/`. + +## Commands + +| Command | Purpose | +|---|---| +| `yarn dev` | Vite dev server (default port from `.env` `VITE_PORT`). | +| `yarn build` | Type-check (`tsc -b`) then production build. | +| `yarn preview` | Serve the built `dist/` for smoke-testing. | +| `yarn lint` | Runs all linters + Prettier write. Use before any commit. | +| `yarn lint:scripts` | ESLint on `.ts` / `.tsx`. | +| `yarn lint:styles` | Stylelint on `.scss`. | +| `yarn lint:editor` | eclint on `src/app`. | +| `yarn sheet2i18n` | Regenerate `assets/locales/{en,fr}.json` from the Google Sheet. Never hand-edit those JSONs. | + +## Instruction files + +Load the ones matching the task. Multi-scope tasks (e.g. "add a new authenticated page with a form that calls an API") legitimately need several at once. + +| File | `applyTo` scope | Load when… | +|---|---|---| +| [typescript.instructions.md](instructions/typescript.instructions.md) | `frontend/src/**/*.{ts,tsx}` | Any TS work — naming (`I`/`E` prefix), interfaces, path aliases, strictness. | +| [react.instructions.md](instructions/react.instructions.md) | `frontend/src/**/*.tsx` | Writing a component, hooks, JSX composition. | +| [components-vs-containers.instructions.md](instructions/components-vs-containers.instructions.md) | `frontend/src/app/{components,containers}/**/*.{ts,tsx}` | Deciding which folder a new piece of UI belongs in. | +| [mui-and-uikit.instructions.md](instructions/mui-and-uikit.instructions.md) | `frontend/src/app/components/**`, `pages/uikit/**`, `themes/**`, `vite.config.ts` | Wrapping an MUI primitive; registering a component in the UiKit page. | +| [styling.instructions.md](instructions/styling.instructions.md) | `frontend/src/**/*.{scss,tsx}`, `themes/**/*.ts` | Any styling decision — utility classes, `styled()`, `sx`, CSS Modules, SCSS. | +| [icons.instructions.md](instructions/icons.instructions.md) | `frontend/src/app/icons/**/*.{ts,tsx}` | Adding or editing an inline-SVG icon. | +| [routing-and-auth.instructions.md](instructions/routing-and-auth.instructions.md) | `frontend/src/app/{routes,pages,hocs}/**/*.{ts,tsx}` | Adding a page, a route, or touching auth / `EPermission`. | +| [forms.instructions.md](instructions/forms.instructions.md) | `frontend/src/app/forms/**/*.{ts,tsx}` | Building a form (Yup schema + controlled state + `FieldHelperText`). | +| [services-api.instructions.md](instructions/services-api.instructions.md) | `frontend/src/app/services/**/*.{ts,tsx}` | Adding an Axios call or a new service domain. | +| [state-stores.instructions.md](instructions/state-stores.instructions.md) | `frontend/src/app/stores/**/*.ts` | Adding or consuming a Zustand store. | +| [i18n.instructions.md](instructions/i18n.instructions.md) | `frontend/src/**/*.tsx`, `assets/locales/**/*.json`, `shared/i18n.ts`, `sheet2i18n.config.cjs` | Anything user-facing text or the `sheet2i18n` workflow. | +| [infra.instructions.md](instructions/infra.instructions.md) | `azure-pipelines.yml`, `azure-pipeline/**`, `terraform/**`, `frontend/{docker-compose.yml,entrypoint.sh,example.env}` | CI/CD, Terraform, per-env variables, local Docker dev. | + +## Repo-wide invariants + +Rules that span every scope. Violating any of these breaks the project's shape. + +- **Yarn 4 only.** Never `npm install`, `pnpm`, `bun`, or ad-hoc `npx`. `yarn.lock` is the lock file. +- **One CI/CD system: Azure Pipelines.** Do not add GitHub Actions, CircleCI, or any parallel pipeline. +- **One router: React Router v6.** No TanStack Router, Next.js router, or hand-rolled routing. +- **One state manager: Zustand (v4).** No Redux, MobX, Jotai, Recoil, or Context-as-store. +- **One form/validation stack: Yup + controlled `useState`.** No `react-hook-form`, `formik`, `final-form`. +- **One i18n stack: i18next + `sheet2i18n`.** Locale JSONs are generated — never hand-edited. +- **One styling stack: MUI v6 + Pigment-CSS.** No Tailwind, no Emotion runtime, no styled-components. +- **No icon library.** Every icon is a hand-wrapped SVG under `@icons/*` — never import from `@mui/icons-material` (it's in `package.json` for historical reasons but has zero imports). +- **No production Dockerfile.** The app deploys as static files; Docker is dev-only. +- **Path aliases everywhere** (`@components`, `@containers`, `@forms`, `@hocs`, `@icons`, `@pages`, `@routes`, `@services`, `@shared`, `@stores`, `@styles`, `@assets`, `@enums`). Never use relative imports across folders. +- **Never hand-edit generated files:** `assets/locales/*.json` (regenerated by `sheet2i18n`), `dist/*` (build output), `yarn.lock` (managed by Yarn). +- **Strict TypeScript.** Do not weaken `strict`, `noUnusedLocals`, `noUnusedParameters`, or `noFallthroughCasesInSwitch`. No `// @ts-ignore` without a linked follow-up. diff --git a/.github/instructions/components-vs-containers.instructions.md b/.github/instructions/components-vs-containers.instructions.md new file mode 100644 index 0000000..b403a3e --- /dev/null +++ b/.github/instructions/components-vs-containers.instructions.md @@ -0,0 +1,210 @@ +--- +description: How to decide between a component and a container, and the folder/import rules for each. +applyTo: "frontend/src/app/{components,containers}/**/*.{ts,tsx}" +--- + +# Components vs Containers + +Scope: everything under `@components` and `@containers`. This file exists because the two folders look similar but exist for opposite reasons — mixing them is the fastest way to make a piece of UI un-reusable. + +Complementary files (do not repeat their content here): +- [react.instructions.md](react.instructions.md) — how to write the component itself, hooks, JSX. +- [typescript.instructions.md](typescript.instructions.md) — naming, interfaces, path aliases. +- [styling.instructions.md](styling.instructions.md) — SCSS/BEM/utility classes. +- [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md) — MUI wrappers and uikit registration. +- [forms.instructions.md](forms.instructions.md) — the third sibling folder alongside components and containers. +- [state-stores.instructions.md](state-stores.instructions.md) — which layers may import `@stores/*`. + +--- + +## The one-sentence rule + +- **Component (`@components`)** = **how things look**. Reusable, dumb, no knowledge of the app. +- **Container (`@containers`)** = **how things work**. Owns state, talks to stores/services/router, composes components. + +If you find yourself importing a store, service, or router hook inside `@components`, you are writing a container in the wrong folder. Move it. + +--- + +## Decision checklist + +Answer top-to-bottom. First "yes" wins. + +1. Does it read/write a Zustand store? → **container** +2. Does it call a service (axios) or `localStorage` / `sessionStorage`? → **container** +3. Does it use `useNavigate`, `useParams`, `useLocation`? → **container** (or a page). +4. Does it fetch data on mount, or run business logic in `useEffect`? → **container** +5. Does it need to know about the current user, permissions, cookie consent, or environment? → **container** +6. Would a design-system Storybook page render it without any providers besides theme/i18n? → **component** +7. Can it be reused on multiple pages, receiving different data via props? → **component** + +If the answer is genuinely "I don't know", start as a component. Promoting a component to a container is easy; extracting a component out of a container is harder. + +--- + +## What belongs in `@components/` + +Presentational only. Every folder under `@components/*` in the repo follows this shape. + +**Allowed:** +- `useState` for **UI-only** state (open/closed, focused, hovered, controlled input value passed back via `onChange`). +- MUI primitives, styled components (see `mui-and-uikit.instructions.md`). +- Props typed as `I extends ` (see [react.instructions.md](react.instructions.md)). +- `useTranslation` **only if** the component owns the translation of its own hard-coded text (rare — usually labels come from the parent per the "translate in parent" rule). Prefer receiving pre-translated strings as props. + +**Not allowed:** +- Imports from `@stores`, `@services`, `@routes`, `@pages`, `@forms`, `@containers`, `@hocs`. +- `useNavigate`, `useLocation`, `useParams`. +- `localStorage`, `sessionStorage`, `document.cookie`, `window.location` mutations. +- Reading `import.meta.env` or `__ENV__` / `__API_URL__` / other Vite globals. +- `useEffect` that performs I/O or navigation. +- Business logic (validation rules, permission checks, currency math). + +Each component folder holds **one** component: + +``` +components/ + / + .tsx + .scss <-- optional, colocated (see styling instructions) +``` + +New MUI wrappers must be registered in the UiKit page (`@pages/uikit`) — see [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md). + +--- + +## What belongs in `@containers/` + +Stateful orchestration. Wraps children, feeds them data, handles side effects. + +**Expected to do:** +- Own non-trivial state (`useState`, `useReducer`) and pass it down. +- Talk to Zustand stores (`useUserStore`, etc.). +- Call services (`postLogin`, `getMe`), handle their promises, translate errors to `toast`. +- Navigate (`useNavigate`), read the URL (`useParams`), react to route changes. +- Read `localStorage` / cookies / env vars. +- Compose presentational components — usually **no markup of its own** beyond a fragment or a thin wrapping `div`. + +**Expected NOT to do:** +- Introduce new styled components or `.scss` files for a look that could be a reusable component. If the visual is worth reusing, extract a component under `@components/`. +- Contain deep JSX trees full of styling. If the container has more than a couple of `div`s doing layout, that layout probably belongs in a component. + +A container typically reads `localStorage` and a store, calls a service, navigates on failure, and renders either a loading state or its children — with zero styling of its own. + +### Container folder layout + +Containers can grow their own private subtree. The full pattern: + +``` +containers/ + / + .tsx <-- the container (default export) + .config.ts <-- static config + Helper.ts <-- pure helpers (I/O + parsing) + interfaces/ + I.ts <-- domain interfaces used by this container + / + .tsx <-- private presentational sub-component + .module.css +``` + +Rules that pattern encodes: +- The container is the **only** thing exported for external use. +- Sub-components live in **lowerCamelCase subfolders** with a **PascalCase file**, and are imported only by their parent container. If a sub-component becomes reusable outside the container, move it to `@components/` and delete the private copy. +- Colocated helpers (`*Helper.ts`), config (`*.config.ts`), and `interfaces/` are the norm. Do not create parallel top-level folders under `@shared` for container-scoped concerns. +- Interfaces exported from a container are still `I`-prefixed and one-per-file (see [typescript.instructions.md](typescript.instructions.md)). + +--- + +## Containers vs Pages + +`@pages` is a third folder that people often confuse with `@containers`. + +| | Container | Page | +|---|---|---| +| Purpose | Reusable stateful piece of UI | A route target | +| Registered in `routes.ts` | No | Yes | +| Wrapped by `withAuth` HOC when protected | No | Yes (via `withAuth.tsx`) | +| Lazy-loaded | No | Yes (`lazy(() => import(...))`) | +| Can be rendered by multiple pages | Yes | No | + +Rule of thumb: if the thing has a URL, it's a page. If it's mounted by a page (or by another container), it's a container. + +Pages and their `*.route.tsx` / `withAuth.tsx` files are covered by `routing-and-auth.instructions.md`. + +--- + +## Common mistakes and how to fix them + +### 1. "Smart component" — component with a store dep + +```tsx +// Bad — components/userGreeting/UserGreeting.tsx +import { useUserStore } from "@stores/userStore"; +export default function UserGreeting() { + const { user } = useUserStore(); + return Hello, {user?.firstName}; +} +``` + +Fix: keep the component dumb, move the store read up. + +```tsx +// components/userGreeting/UserGreeting.tsx +interface IUserGreeting { name?: string; } +export default function UserGreeting({ name }: IUserGreeting) { + return Hello, {name}; +} + +// caller (page or container) +const { user } = useUserStore(); + +``` + +### 2. "Layout container" — container with heavy markup + +If your container has a `div`-soup that looks like design work, the markup is a component. Keep the container as pure orchestration: + +```tsx +// containers/checkout/Checkout.tsx +export default function Checkout() { + const { items, total } = useCartStore(); + const onSubmit = useCallback(/* ... */, []); + return ; +} + +// components/checkoutSummary/CheckoutSummary.tsx — all the JSX + styling +``` + +### 3. Sub-component that got reused + +A private sub-component under `containers///` stays private until a second consumer appears. The moment it does, promote it: + +``` +components// + .tsx + .scss +``` + +...and delete the private copy from the container. + +### 4. Business logic inside a component + +A component asking "is this user allowed to see the button?" is a container in disguise. The component should receive `showButton: boolean` and render it; the container computes the boolean from `useUserStore()` / `EPermission`. + +--- + +## Import allowlist summary + +| From ↓ / To → | `@components` | `@containers` | `@stores` | `@services` | `@routes` / `@pages` | `@hocs` | `@shared` / `@enums` | MUI / third-party | +|---|---|---|---|---|---|---|---|---| +| `@components/*` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (types, constants only) | ✅ | +| `@containers/*` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +If an import you need is a ❌ from `@components/*`, the file belongs in `@containers/*` (or `@pages/*`) instead. + +--- + +## Not sure? + +Don't take the component vs container separation as a dogma — sometimes the line is hard to draw. Default to a component. Move it to a container only when it starts needing the app to work. diff --git a/.github/instructions/forms.instructions.md b/.github/instructions/forms.instructions.md new file mode 100644 index 0000000..162507a --- /dev/null +++ b/.github/instructions/forms.instructions.md @@ -0,0 +1,405 @@ +--- +description: Form conventions — folder layout, Yup schemas, controlled state, submit + blur validation, and FieldHelperText wiring. +applyTo: "frontend/src/app/forms/**/*.{ts,tsx}" +--- + +# Forms + +Scope: everything under `@forms/*` — the form component and its Yup schema. Forms are their own folder alongside `@components` / `@containers` because they combine both concerns (state + orchestration + presentational children). + +Complementary files (do not repeat their content here): +- [react.instructions.md](react.instructions.md) — component shape, hooks, `Dispatch` prop typing. This file expands the "Forms" section there. +- [services-api.instructions.md](services-api.instructions.md) — the `.then / .catch / .finally` service call pattern and `toast.error` conventions used in `onSubmit`. +- [i18n.instructions.md](i18n.instructions.md) — `validations__*` namespace, why every schema label / message is an i18n key. +- [typescript.instructions.md](typescript.instructions.md) — `I` prefix for props, file naming. +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — how forms differ from containers. + +Validation library: `yup` v1. Form UI primitives come from `@components/*` (`TextField`, `Button`, `FieldHelperText`). + +--- + +## Folder layout + +``` +forms/ + / <-- feature grouping (auth, project, cart, …) + / <-- one folder per form, camelCase + .tsx <-- default export, PascalCase file name + .schema.ts <-- default export schema, camelCase file name +``` + +Rules: +- **One folder per form**, even if it holds just two files. Never colocate multiple forms in the same folder. +- **File name = default export name** (see [typescript.instructions.md](typescript.instructions.md#naming-conventions)). Form component in PascalCase; schema file in camelCase. +- **Schema always in `.schema.ts`**, next to the component. Never inside the `.tsx`. +- **No `interfaces/` subfolder** for form-local props — inline the `I` interface at the top of the `.tsx`. Domain models (like `ILogin`) belong under the owning service (see [services-api.instructions.md](services-api.instructions.md)). + +--- + +## The schema file + +Yup schemas are small, declarative, and use i18n keys for everything a user might see: + +```ts +import { object, string } from "yup"; + +const loginFormSchema = object({ + username: string().label("login__username").required("validations__required"), + password: string() + .label("login__password") + .min(8, "validations__min_characters"), +}); + +export default loginFormSchema; +``` + +Rules: + +1. **Named imports** from `yup` (`import { object, string, number, boolean, array, mixed, date, ref } from "yup"`). Never `import * as yup`. +2. **Default export** the schema constant, named `Schema`. +3. **`.label()` receives an i18n key**, not the human-readable label. `FieldHelperText` translates it and injects it as `{{ field }}` into the error string (see the nested-translation pattern in [i18n.instructions.md](i18n.instructions.md#nested-translation-for-label-fields)). +4. **Validation messages are `validations__*` i18n keys**. Never inline strings like `"Password is required"`. Reuse existing keys: + - `validations__required` + - `validations__min_characters` (interpolates `{{ min }}`) + - `validations__max_characters` (interpolates `{{ max }}`) + - Add a new key via `sheet2i18n` if a genuinely new message is needed. +5. **Field names match the state shape** exactly. `FieldHelperText` matches on `error.path === fieldNames`, so a mismatch silently hides errors. +6. **No `.oneOf([true])` / `.matches(...)` without a message key.** Every validator that can fail carries an i18n key as its message argument. +7. **No `.transform` for i18n** — keep the schema pure validation. Formatting belongs in the component. + +### Cross-field validation + +Use `ref` from yup for comparisons and pass the same key convention: + +```ts +import { object, ref, string } from "yup"; + +const registerFormSchema = object({ + password: string().label("register__password").required("validations__required"), + passwordConfirm: string() + .label("register__password_confirm") + .oneOf([ref("password")], "validations__passwords_must_match"), +}); +``` + +If `validations__passwords_must_match` isn't in the Sheet yet, add it there **first** — do not hand-edit the locale JSON (see [i18n.instructions.md](i18n.instructions.md#the-one-rule-that-trumps-everything)). + +--- + +## The form component + +Every form follows the same shape. + +### Props + +- Interface `I` at the top of the file, non-exported. +- **Loading is owned by the parent.** A form takes `setIsLoading: Dispatch>` and flips it around the async submit. The parent page renders `` — the form does not. +- Do not accept an `onSubmit` prop that abstracts the service call. Forms are tightly bound to one service call; keep the call inside the form. +- Do accept callbacks for cross-cutting parent concerns (e.g., `onSuccess?: () => void`) if the parent needs to react beyond the built-in navigation/toast. + +```tsx +interface ILoginForm { + setIsLoading: Dispatch>; +} + +export default function LoginForm({ setIsLoading }: ILoginForm) { /* ... */ } +``` + +### State — three pieces, in this order + +```tsx +const [loginForm, setLoginForm] = useState({ + username: "", + password: "", +}); +const [loginFormValidated, setLoginFormValidated] = useState(false); +const [formErrors, setFormErrors] = useState([]); +``` + +- **Values as a single object** typed against the request interface from the owning service (`ILogin` here). Never one `useState` per field. +- **`Validated` boolean** — flipped to `true` on first submit; gates re-validation on blur so the user isn't yelled at while typing the first time. +- **`formErrors: ValidationError[]`** from Yup's `error.inner`. Do not flatten to strings; `FieldHelperText` needs the full `ValidationError` shape. + +### `onSubmit` template + +```tsx +const onSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + try { + setLoginFormValidated(true); + loginFormSchema.validateSync(loginForm, { abortEarly: false }); + setFormErrors([]); + setIsLoading(true); + postLogin(loginForm) + .then(({ data }) => { + // success side effects — tokens, store, navigate + }) + .catch((error) => { + if (error.response?.data?.message === "Invalid credentials") { + toast.error(t("errors__invalid_credentials"), { toastId: "invalid-credentials" }); + } else { + toast.error(t("errors__generic"), { toastId: "generic" }); + } + }) + .finally(() => { + setIsLoading(false); + }); + } catch (error) { + if (error instanceof ValidationError) { + setFormErrors(error.inner); + } + } + }, + [loginForm, /* ...other deps used in the body */], +); +``` + +Rules: +- **`event.preventDefault()` first** — always. +- **`setLoginFormValidated(true)` before `validateSync`** — so subsequent blur re-validation is enabled. +- **`validateSync(values, { abortEarly: false })`** — collect all errors, not just the first. Wrap in `try/catch` because it throws on failure. +- **Clear `formErrors` on validation success**, before the async call. Stale errors must not linger past a valid submit. +- **Only call the service after validation passes.** The service call is inside the `try` block so failed validation short-circuits. +- **Service call follows [services-api.instructions.md](services-api.instructions.md#where-services-get-called)** — `.then` with destructured `{ data }`, `.catch` with known errors first + `errors__generic` fallback, `.finally` to reset loading. +- **The `catch (error)` at the outer `try` handles Yup errors only** — `error instanceof ValidationError` guard, then `setFormErrors(error.inner)`. Do not toast validation errors; they render inline via `FieldHelperText`. + +### `onValidate` for `onBlur` + +Re-runs the schema silently once the user has attempted a submit. Prevents a fixed field from staying red until the next submit. + +```tsx +const onValidate = useCallback(() => { + try { + if (loginFormValidated) { + loginFormSchema.validateSync(loginForm, { abortEarly: false }); + setFormErrors([]); + } + } catch (error) { + if (error instanceof ValidationError) { + setFormErrors(error.inner); + } + } +}, [loginForm, loginFormValidated]); +``` + +Rules: +- **Guarded by the `Validated` flag** — do nothing if the user hasn't tried to submit yet. +- **Wired to every field's `onBlur`**, not `onChange` (avoid re-validating on every keystroke). + +### JSX layout + +Each field is a `
` wrapping the input **and** its `FieldHelperText`. Submit is a `Button type="submit"`. + +```tsx +return ( +
+
+ + setLoginForm((prevState) => ({ ...prevState, username: value })) + } + label={t("login__username")} + /> + +
+ +
+ + setLoginForm((prevState) => ({ ...prevState, password: value })) + } + label={t("login__password")} + /> + +
+ + +
+); +``` + +Rules: +- **Native `
`** — never `onClick` on the submit button. Preserves keyboard `Enter` submission. +- **`TextField.onChange` receives `value: string`** — the repo's `TextField` wrapper converts the event for you (see [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md#the-wrapper-pattern)). +- **State updates use the functional form**: `setLoginForm((prevState) => ({ ...prevState, field: value }))`. +- **`autoComplete` attributes** on relevant inputs (`username`, `current-password`, `new-password`, `email`, `given-name`, `family-name`, `postal-code`, `off`). Password managers need them; do not omit. +- **Labels translated at the call site**: `label={t("login__username")}` — the child gets a string, not a key (see [i18n.instructions.md](i18n.instructions.md#the-translate-in-parent-pass-a-string-down-rule)). +- **Spacing via utility classes** (`mb-md`, `flex-column`), never inline styles (see [styling.instructions.md](styling.instructions.md#utility-classes-the-first-stop)). + +--- + +## `FieldHelperText` — the error/helper bridge + +`FieldHelperText` from `@components/fieldHelperText` is the only component allowed to render Yup errors in a form. It handles: + +- Matching `formErrors` on `path === fieldNames`. +- Translating `error.message` (a `validations__*` key) with the field's `label` (also a key) as the `{{ field }}` interpolation value. +- Falling back to `helperText` (already translated) when there is no error. + +Contract: + +```tsx + +``` + +Rules: +- **`fieldNames` must match the schema field name** exactly. Typo → the error silently disappears. +- **`helperText` is pre-translated** by the caller. Do not pass a raw i18n key. +- **Do not render errors any other way** (no manual `.map(formErrors)`, no ``, no `alert`). All error UI goes through `FieldHelperText` so styling and animation stay consistent — the shared error component wraps each message in a fade transition. + +--- + +## Loading UX — parent owns the overlay + +The `` component is a full-screen overlay. It belongs on the **page**, not inside the form. + +```tsx +// parent page — owns the Loading overlay +const [isLoading, setIsLoading] = useState(false); +return ( + <> + + + {/* ... */} + + + +); +``` + +Rationale: +- The overlay must sit above the form's parent layout, not inside it. +- Multiple sibling forms on one page share a single overlay. +- The form stays focused on inputs + validation + submit. + +For inline "spinner in button" affordances (rarely needed today) use MUI's built-in button loading props rather than an overlay. + +--- + +## Where forms sit vs. containers + +A form is **more specific than a container**: it always represents one submit-to-service action. Rule of thumb: + +- Has a schema + one primary submit? → `@forms///`. +- Owns cross-page state or a subscription with no single submit? → `@containers//`. + +Both are allowed to import from `@services`, `@stores`, `@routes`, etc. Both are called from pages (or from each other). See [components-vs-containers.instructions.md](components-vs-containers.instructions.md). + +--- + +## Common mistakes and how to fix them + +### 1. Inline validation message strings + +```ts +// Bad — hardcoded, unlocalizable +username: string().required("Username is required"), +``` + +```ts +// Good +username: string().label("login__username").required("validations__required"), +``` + +### 2. Field-per-`useState` + +```tsx +// Bad +const [username, setUsername] = useState(""); +const [password, setPassword] = useState(""); +// FieldHelperText won't get a schema-shaped object without extra plumbing +``` + +```tsx +// Good +const [loginForm, setLoginForm] = useState({ username: "", password: "" }); +``` + +### 3. Submitting without the `Validated` flag + +Symptom: on-blur validation fires the moment the user tabs out of an empty field, before they've ever tried to submit. Fix: introduce the flag and gate `onValidate` on it (see the reference `onValidate`). + +### 4. Toasting validation errors + +```tsx +// Bad — validation errors belong inline +if (error instanceof ValidationError) { + toast.error(t("errors__form_validation")); +} +``` + +```tsx +// Good — inline via FieldHelperText +if (error instanceof ValidationError) { + setFormErrors(error.inner); +} +``` + +Reserve `errors__form_validation` for the (rare) case where the parent page needs a top-level warning — the form itself renders per-field. + +### 5. Submit button `onClick` instead of form `onSubmit` + +```tsx +// Bad — breaks Enter-key submit + +``` + +```tsx +// Good + + {/* ... */} + + +``` + +### 6. Form owning its own `Loading` + +```tsx +// Bad — overlay renders under the layout, layered wrongly +return ( +
+ + {/* fields */} + +); +``` + +Hoist `isLoading` to the page and receive `setIsLoading` as a prop. + +### 7. Skipping `abortEarly: false` + +Without it, `validateSync` throws on the first failure — only one error surfaces at a time. Always pass `{ abortEarly: false }`. + +--- + +## Things to avoid + +- Inline schema definition inside the `.tsx`. +- Multiple `useState` per field. +- Inline validation messages (must be `validations__*` keys). +- `.label(t("..."))` — pass the raw key; `FieldHelperText` translates. +- Using anything other than `FieldHelperText` to render Yup errors. +- Submitting via a button `onClick` instead of `
`. +- Rendering `` inside a form. +- Introducing a form-management library (`react-hook-form`, `formik`, `final-form`) — the repo has one pattern. +- `async` `onSubmit` handlers — prefer explicit `.then/.catch/.finally` to match the codebase's service-call convention. +- Calling `schema.validate(...)` (async) instead of `validateSync` — the repo uses sync validation everywhere. +- Storing loading, error, or submit state on a Zustand store — form state stays local. +- Missing `autoComplete` on `` for common fields. diff --git a/.github/instructions/i18n.instructions.md b/.github/instructions/i18n.instructions.md new file mode 100644 index 0000000..2b126ec --- /dev/null +++ b/.github/instructions/i18n.instructions.md @@ -0,0 +1,269 @@ +--- +description: i18n conventions — sheet2i18n workflow, key naming, useTranslation usage, interpolation, plurals, and language switching. +applyTo: "frontend/src/**/*.tsx,frontend/src/assets/locales/**/*.json,frontend/src/app/shared/i18n.ts,frontend/src/sheet2i18n.config.cjs" +--- + +# i18n + +Scope: everything that touches translation — the i18next setup, locale JSONs, `t()` usage in components, and the `sheet2i18n` workflow that generates the JSONs. + +Complementary files (do not repeat their content here): +- [routing-and-auth.instructions.md](routing-and-auth.instructions.md) — how `locale__key`, `locale__switch_key`, and `routes__` power localized URLs; `findRoute` for language switching. +- [react.instructions.md](react.instructions.md) — the "translate in parent, pass a string to the child" rule (restated here with a full example). +- [forms.instructions.md](forms.instructions.md) — how Yup validation keys map through `FieldHelperText`. +- [services-api.instructions.md](services-api.instructions.md) — `toast.error(t("errors__..."))` conventions with a stable `toastId`. +- [typescript.instructions.md](typescript.instructions.md) — path aliases and file naming. + +Stack: `i18next` + `react-i18next` + `i18next-browser-languagedetector`. Two supported locales today: `en`, `fr`. Setup lives at `@shared/i18n`. + +--- + +## The one rule that trumps everything + +**Never hand-edit `en.json` or `fr.json`.** They are **generated** from a Google Sheet by `yarn sheet2i18n`. Any manual edit is overwritten on the next sync. + +Workflow for any new / changed translation: + +1. Open the Google Sheet configured in `sheet2i18n.config.cjs`. +2. Edit the appropriate tab (Global, Components, Routes, Validations, Errors, Home, Login, Uikit, …). +3. Run `yarn sheet2i18n` from `frontend/`. +4. Commit the regenerated `@assets/locales/*.json` alongside the code change that uses the new keys. + +If a task requires a translation key that doesn't exist, do **not** invent it in the JSON — flag it as a Sheet edit for the human. The single-source-of-truth is the Sheet. + +> Each project gets its **own** Google Sheet. Do not reuse the template's Sheet. + +--- + +## Setup summary + +| File | Role | +|---|---| +| `@shared/i18n` | Initializes i18next with `LanguageDetector` + `initReactI18next`. Registers `en.json` / `fr.json` as resources. Exports the `i18n` instance. | +| `@assets/locales/en.json`, `@assets/locales/fr.json` | Generated key/value maps — one flat object per locale. | +| `sheet2i18n.config.cjs` | Lists the published-CSV URL for every Sheet tab, plus `localesKey: ["en", "fr"]`. | +| `@shared/constants` | `EN = "en"`, `FR = "fr"` — the string constants used everywhere the locale key is compared. | + +Key config choices (do not change casually): + +- `fallbackLng: EN` — English is the fallback. +- `supportedLngs: [FR, EN]` — locale detector only accepts these two. +- `detection: { order: ["path", "navigator"] }` — locale is read from the URL first (`/en/...` or `/fr/...`), then from browser preference. +- `interpolation.escapeValue: false` — React already escapes; do not re-enable. +- `returnNull: false` — missing keys return the key string, not `null`. Type-augmented in the same file: + ```ts + declare module "i18next" { + interface CustomTypeOptions { returnNull: false; } + } + ``` + +The setup is imported for side effects in `App.tsx` (`import "@shared/i18n";`) and explicitly for `i18n.changeLanguage(...)` where language switching happens. + +--- + +## Locale JSON conventions + +Every key follows `__` — **double underscore** between namespace and name. Namespaces group related keys and match the Sheet tabs. + +### Namespaces currently in use + +| Namespace | Purpose | Examples | +|---|---|---| +| `global` | App-wide UI text | `global__close`, `global__hide`, `global__clipboard_copy` | +| `routes` | Localized URL segments **and** the shared page-title suffix | `routes__login`, `routes__home`, `routes__page_title` | +| `validations` | Yup validation error messages (support interpolation) | `validations__required`, `validations__max_characters` | +| `errors` | User-facing error copy (used with `toast.error`) | `errors__generic`, `errors__invalid_credentials`, `errors__expired_session` | +| `` | Page-scoped copy | `home__welcome`, `login__sign_in`, `dashboard__page_title` | +| `cookie_banner`, `cookie_modal` | Container-scoped copy | `cookie_banner__accept_all`, `cookie_modal__title` | + +### Reserved keys — do not rename or remove + +| Key | Consumed by | Effect if broken | +|---|---|---| +| `locale` | Human-readable language label. | UI display only. | +| `locale__key` | Everywhere navigation happens (`route.paths[t("locale__key")]`), `Router.tsx`, ``. | Breaks all localized navigation. | +| `locale__switch` | Label on the language toggle. | UI display only. | +| `locale__switch_key` | `findRoute(...)` + `i18n.changeLanguage(...)` when switching language. | Breaks language switch. | +| `routes__` | Every `.route.tsx` builds its URL from this. | URL 404s after regeneration. | +| `__page_title` | `Router.tsx` sets the tab title from this. | Tab title falls back to the raw key. | +| `routes__page_title` | Global app title suffix in the tab. | Tab title reads ` - routes__page_title`. | + +Rename a reserved key → break the app. If you must rename, do it in the Sheet, regenerate, and update every code site in the same commit. + +### Style rules + +- Keys are `snake_case` after the namespace: `home__welcome`, not `home__Welcome` or `home__welcomeMessage`. +- Interpolation placeholders use `{{ name }}` (with spaces): `"{{ field }} is required."`. Match the naming already in `validations__*`. +- Do not nest objects in the JSON. The generator produces a **flat** map; keep it that way. +- No JSX or HTML in values. If you need markup, split the string and compose in JSX. +- Values are shipped as-is to the browser — no escaping, no HTML entities (`'` not `'`). React handles character encoding. + +--- + +## Using translations in components + +```tsx +import { useTranslation } from "react-i18next"; + +function Home() { + const { t } = useTranslation(); + return {t("home__welcome")}; +} +``` + +Rules: + +- Import `useTranslation` from `react-i18next`, never from `i18next`. +- Prefer the object form `const { t } = useTranslation();`. The array form `const [t] = useTranslation();` also works and appears in the repo — either is fine, but pick one within a file. +- Call `t()` at the point of rendering. Do not stash `t("...")` in a module-level constant — it runs before i18next initializes. +- **Do not concatenate half-sentences** (`t("common__hello") + " " + name`). Use interpolation instead — see below. This lets translators reorder words for languages that need it. + +### The "translate in parent, pass a string down" rule + +Repeated from [react.instructions.md](react.instructions.md#props) because it is the most common i18n mistake. A child component receives an already-translated `string`, not a translation key. + +```tsx +// Bad — child does the translation +interface IButton { labelKey: string; } + +``` + +The only exceptions in the repo are wrappers whose entire job is to look up an i18n key by construction (e.g. `FieldHelperText` mapping Yup error paths to `validations__*` keys). New components should follow the "string in, string rendered" rule. + +--- + +## Interpolation + +Use i18next placeholders (`{{ name }}`) — never `.replace()`, template literals, or manual string surgery. + +```json +{ + "plan__co_member_fee_label": "Co-member fee: {{ coMemberFee }} ({{ coMemberFeeType }})" +} +``` + +```tsx +t("plan__co_member_fee_label", { + coMemberFee: formatCurrency(amount), + coMemberFeeType: feeTypeLabel, +}); +``` + +Rules: + +- Placeholder names match the object keys **case-sensitively** and include the spaces around them in the JSON (`{{ field }}`, not `{{field}}`). +- Values passed in can be already-translated strings, formatted numbers/currencies (`dayjs`, `formatCurrency`), or raw numbers/booleans. +- **Do not** pass raw HTML. React would render it escaped; if you truly need markup, split the string and compose in JSX around a ``. + +### Nested translation for label fields + +Validation and other "field name inside a sentence" cases translate the field name **before** passing it to the outer `t()`: + +```tsx +// FieldHelperText — matches a Yup error to its label key, translates both +t(error.message, { + ...error.params, + max: error.params?.max, + min: error.params?.min, + field: t(error.params?.label as string), // translate the label key first +}); +``` + +Follow this pattern any time a translation embeds another translated term. + +--- + +## Plurals + +Use i18next's `count` mechanism, not conditional `t()` calls. + +```json +{ + "asset__share_modal_title": "Share asset", + "asset__share_modal_title_other": "Share assets" +} +``` + +```tsx +// Good +t("asset__share_modal_title", { count: assets.length }); + +// Bad — conditional call, doesn't scale to zero / few / many plural rules +{ assets.length === 1 + ? t("asset__share_modal_title") + : t("asset__share_modal_title_other") } +``` + +Add the `_other` (and `_zero` / `_few` / `_many` if the target language needs them) suffixes to the Sheet key alongside the base form. Do not add them manually to the JSON. + +--- + +## Language switching + +The full mechanics live in [routing-and-auth.instructions.md](routing-and-auth.instructions.md#locale-switch--findroute). The one-line summary: **switch is a pair of calls, in this order:** + +```tsx +import i18n from "@shared/i18n"; + +const onChangeLanguage = useCallback(() => { + navigate(findRoute(location.pathname, t("locale__switch_key"))); + i18n.changeLanguage(t("locale__switch_key")); +}, [navigate, t]); +``` + +Do not call `i18n.changeLanguage` without also rewriting the URL — the detector will flip it back on the next reload because `detection.order` is `["path", "navigator"]`. + +--- + +## Error and toast copy + +- Toasts always use i18n keys under the `errors__*` (or feature-scoped) namespace and pass a **stable `toastId`** to prevent duplicates: + +```tsx +toast.error(t("errors__invalid_credentials"), { toastId: "invalid-credentials" }); +toast.error(t("errors__generic"), { toastId: "generic" }); +toast.error(t("errors__expired_session"), { toastId: "expired-session" }); +``` + +- The `toastId` is a stable slug — matching the key name minus the namespace is a fine default. Never omit it: without it, a burst of failed requests stacks N identical toasts. +- Reuse existing `errors__generic` for unclassified failures; only add a new key when the message is genuinely distinct copy. + +--- + +## Adding a new locale + +Requires touching multiple layers in one commit. Recipe: + +1. **Sheet**: add the new column (e.g. `es`) in every tab, translate every row. +2. **`sheet2i18n.config.cjs`**: add `"es"` to `localesKey`. +3. Run `yarn sheet2i18n` — this creates `src/assets/locales/es.json`. +4. **`src/app/shared/constants.ts`**: `export const ES = "es";`. +5. **`src/app/shared/i18n.ts`**: import `es from "@assets/locales/es.json"`, add to `resources` and to `supportedLngs`. +6. **`src/app/routes/interfaces/IRoute.ts`**: add `es: string;` to `IPaths`. +7. **Every `*.route.tsx`**: add the `es` path built from the new locale JSON keys. +8. **UI wherever a language switch is offered**: extend past the current binary `en ↔ fr` toggle (today powered by `locale__switch_key`, which assumes exactly two locales). + +Step 8 is the non-obvious one — the current `locale__switch_key` mechanism is designed for a two-locale toggle. Adding a third locale requires a real language selector, not just another key. + +--- + +## Things to avoid + +- Hand-editing `en.json` or `fr.json` — always go through the Sheet + `yarn sheet2i18n`. +- Adding a translation key in code that doesn't exist yet in the JSON. i18next returns the key string as a fallback and the missing text ships unnoticed. Add to the Sheet **first**. +- `.replace("{name}", value)` on a translated string — use interpolation. +- Conditional `t()` for plurals — use `count`. +- Storing `t("...")` at module scope, in a class field, or in a store default — always call inside a component body / hook. +- Translating twice: `t(t("foo"))`. If you see this, the child is being handed a key when it should be handed a string. +- Concatenating translated fragments (`t("hello") + " " + name`). Use `{{ name }}` in the JSON. +- Reading the locale from anywhere except `t("locale__key")` — no `navigator.language`, no `location.pathname.split("/")[1]`, no store field. +- Reaching for a namespaced i18next API (`t("common:hello")`) — the app uses a single default namespace. +- Introducing HTML in a translation value. +- Bypassing `i18n.changeLanguage` (e.g. only editing the URL) — the runtime language stays stale. +- Passing a Yup validation message string that isn't a key — validation messages **must** be `validations__*` keys so `FieldHelperText` can translate them. diff --git a/.github/instructions/icons.instructions.md b/.github/instructions/icons.instructions.md new file mode 100644 index 0000000..c83c97f --- /dev/null +++ b/.github/instructions/icons.instructions.md @@ -0,0 +1,330 @@ +--- +description: Icon conventions — the IIcon contract, SVG template, theming via sx, defaults, and usage patterns. +applyTo: "frontend/src/app/icons/**/*.{ts,tsx}" +--- + +# Icons + +Scope: everything under `@icons/*`. Icons are hand-wrapped inline SVGs, one file per icon, all conforming to the shared `IIcon` contract. + +Complementary files (do not repeat their content here): +- [typescript.instructions.md](typescript.instructions.md) — `I` prefix, default-export naming, file naming. +- [react.instructions.md](react.instructions.md) — functional-component shape. +- [styling.instructions.md](styling.instructions.md) — `sx` callback form, theme palette tokens, utility classes. +- [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md) — why the repo doesn't use `@mui/icons-material`. +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — icons are assets, not components; they get their own folder. + +**No icon library.** The repo does **not** use `@mui/icons-material`, `react-icons`, `lucide-react`, `heroicons`, or Font Awesome. Every icon is an inline SVG wrapped as a React component under `@icons/*`. + +--- + +## Folder layout + +``` +icons/ + IIcon.ts <-- shared props interface, .ts (no JSX) + .tsx <-- one file per icon, PascalCase, default export +``` + +The shared props contract lives in `IIcon.ts`, alongside every icon file. + +Rules: +- **Flat folder.** No subfolders per icon or per category. +- **File name = component name** (PascalCase, `.tsx`). Default export. See [typescript.instructions.md](typescript.instructions.md#exports). +- **`IIcon.ts` is `.ts`**, not `.tsx` — no JSX in the shared props file. +- **Path alias `@icons`** — import as `import CaretIcon from "@icons/CaretIcon";`. Never relative. +- **No barrel `index.ts`.** Consumers import each icon by name. +- **Naming convention:** describe the shape or action, PascalCase. Follow the source's suffix if there's a variant family (`Rounded`, `Outlined`, `Sharp`, `Filled`). Examples: `AddRounded`, `ExternalLinkOutlined`, `CookieIcon`. + +--- + +## The `IIcon` contract + +Every icon accepts **exactly** the shared `IIcon` interface — no more, no less: + +```ts +export default interface IIcon { + className?: string; + color?: string; + width?: number; + height?: number; + alt?: string; +} +``` + +Rules: +- **Never add per-icon props** by extending `IIcon` for one file. If a single icon truly needs an extra input (e.g., an animation toggle), define a local interface in that `.tsx` and don't touch `IIcon.ts`. +- **Never widen `IIcon`** with fields that most icons will ignore. The interface is the contract every consumer relies on. +- **`color?: string` is currently unused** by the existing icons — theming happens through `sx` (see below). New icons that genuinely need runtime color overrides can wire it, falling back to a theme token. Do not remove the field. + +--- + +## The icon template + +Copy this shape verbatim. Only three things change per icon: the name, the `viewBox` / defaults, and the `` data. + +```tsx +import IIcon from "./IIcon"; + +export default function AddRounded({ + className, + width = 24, + height = 24, + alt = "Add Rounded", +}: IIcon) { + return ( + + {alt} + ({ + fill: theme.palette.common.white, + })} + /> + + ); +} +``` + +Rules (in order): + +1. **Import `IIcon` from `./IIcon`** — the one relative import allowed inside `@icons/*`, because it's a sibling file. Anywhere else, use `@icons/...`. +2. **`function ({ … }: IIcon)`** with defaults destructured inline. No separate `defaultProps`. +3. **Defaults:** + - `width` and `height` default to the SVG's natural `viewBox` size (typically `24`). Never mix — `width = 24` with `height = 18` distorts. + - `alt` defaults to a human-readable version of the component name (`"Add Rounded"`, not `"AddRounded"` or `"add-rounded"`). Consumers can override with a domain-specific label. + - `className` and `color` do **not** get defaults. +4. **`` attributes in this exact order:** `className`, `width`, `height`, `viewBox`, `fill="none"`, `xmlns="http://www.w3.org/2000/svg"`. + - `fill="none"` sits on the `` so per-path `fill` (via `sx`) wins. Do not add `fill="currentColor"` — the repo doesn't rely on CSS `color` inheritance. + - Keep the `xmlns` attribute even though React strips it in some places; it's expected by tooling and consistent with the codebase. +5. **`{alt}` is always the first child of ``.** Non-negotiable — it's the accessible name for screen readers. Skipping it breaks a11y and lint expectations. +6. **Color is set on each `` via `sx`**, callback form only, always via a theme token. See [styling.instructions.md](styling.instructions.md#the-sx-prop-escape-hatch-with-rules). + - `sx={(theme) => ({ fill: theme.palette. })}` — never `fill="#123456"`, never `fill="white"`. + - If several ``s share the same fill, repeat the `sx` block on each. **Do not** hoist to the `` unless the whole graphic is monochrome and every child inherits (see the monochrome shortcut below). +7. **Palette tokens actually used today:** + - `theme.palette.common.white` — icons rendered on colored buttons. + - `theme.palette.grey[800]` — neutral UI chrome. + - `theme.palette.primary.main` — brand-colored accents. + - Pick from these first. Only introduce a new token when no existing one fits. + +### Monochrome shortcut — fill on `` + +When every shape in the icon shares one color, put `sx` on the `` and drop it from each ``. + +```tsx + ({ + fill: theme.palette.primary.main, + })} + xmlns="http://www.w3.org/2000/svg" +> + {alt} + + + +``` + +Rules: +- **Drop `fill="none"` from the ``** when using this pattern — the `sx` fill needs to reach the children. +- **Do not mix** per-`` `sx` with an ``-level `sx` fill in the same icon. Pick one. + +### Multi-color icons + +Wire the fill on each `` / `` / `` individually, one `sx` block per element. This gives you a per-shape color even inside a single icon. + +--- + +## Sizing and non-24 icons + +Not every icon is 24×24. Set the defaults so `width` and `height` match the SVG's `viewBox` exactly — e.g. `viewBox="0 0 16 16"` → `width = 16, height = 16`. + +Rules: +- **Never scale a non-24 icon by passing `width={24}`** at the call site — the aspect ratio distorts. Create a new sized variant (`Large`) or ask the designer for the correct source. +- **Prefer sizing via the `width` / `height` props**, not via CSS `transform: scale(...)`. Scaling blurs SVGs on some browsers. +- For spacing around the icon, use utility classes on the icon element itself (`className="mr-xs"` next to a label), not width/height changes. See [styling.instructions.md](styling.instructions.md#utility-classes-the-first-stop). + +--- + +## Consuming icons + +Common patterns: + +```tsx +// Icon next to a button label + + +// Icon as an accordion expand indicator + + +// Decorative icon inside a banner + +``` + +Rules: +- **`className` carries spacing**, not size. Size overrides are rare and always require both `width` and `height`. +- **Set a domain-specific `alt`** when the icon is the only affordance in a control (e.g., an icon-only button). When the icon is decorative next to labeled text, the default `alt` is fine. +- **Do not wrap icons in `` or `
` for styling.** Put utility classes directly on the icon. +- **Icons live inside interactive elements (`Button`, `IconButton`), not the other way around.** See [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md) for the wrapper components. + +--- + +## Adding a new icon — checklist + +1. Get the SVG source (Figma export, design system, third-party asset). Confirm the `viewBox` and that it's a **single-color monochrome** unless the design truly requires multi-fill. +2. Run it through an SVG optimizer (SVGO or equivalent) — keep only `` / basic shape elements, the `viewBox`, and geometry attributes. Strip `id`, `class`, `style`, inline `fill` / `stroke`, `stroke-width` unless meaningful, `data-*`. +3. Copy the appropriate template above (monochrome shortcut or per-path multi-color) and adapt. +4. Rename the file and the component. `alt` default is a spaced version of the name. +5. Wire the fill via `sx` callback → theme token. Do not paste raw hex. +6. Import from `@icons/` at the call site. + +--- + +## Common mistakes and how to fix them + +### 1. Hardcoded fill + +```tsx +// Bad + +``` + +```tsx +// Good — theme-driven, dark-mode-friendly + ({ fill: theme.palette.primary.main })} +/> +``` + +### 2. Missing `` + +```tsx +// Bad — no accessible name +<svg …> + <path … /> +</svg> +``` + +```tsx +// Good +<svg …> + <title>{alt} + + +``` + +### 3. Distorted resize + +```tsx +// Bad — 16×16 icon forced to 24 wide, keeps 16 tall → squashed + +``` + +```tsx +// Good — both dimensions, matched proportions + +``` + +### 4. Growing `IIcon` for a single case + +```ts +// Bad — every icon now advertises a prop it ignores +export default interface IIcon { + className?: string; + color?: string; + width?: number; + height?: number; + alt?: string; + spinning?: boolean; // used by one icon +} +``` + +```tsx +// Good — local extension inside that icon only +interface ISpinnerIcon extends IIcon { + spinning?: boolean; +} +export default function SpinnerIcon({ spinning, …rest }: ISpinnerIcon) { … } +``` + +### 5. Pulling from `@mui/icons-material` + +```tsx +// Bad — pulls in a new dependency, bypasses the theming pipeline +import AddIcon from "@mui/icons-material/Add"; +``` + +```tsx +// Good — wrap the SVG once, reuse everywhere +import AddRounded from "@icons/AddRounded"; +``` + +### 6. Sizing with CSS `transform` + +```tsx +// Bad — blurry on subpixel scales, and utility classes aren't for this + +``` + +```tsx +// Good + +``` + +### 7. Wrapping icons in a `
` for spacing + +```tsx +// Bad +
+``` + +```tsx +// Good + +``` + +### 8. `fill="currentColor"` and inherited CSS color + +```tsx +// Bad — the repo doesn't rely on CSS color inheritance for icons + +``` + +Use `sx` + theme token instead. It's a single source of truth and works with Pigment-CSS statically. + +--- + +## Not currently registered in the UiKit page + +Icons are treated as **assets**, not UI components, and are **not** registered in `@pages/uikit` today. The rule from [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md#registering-a-new-component-in-the-uikit-page) applies to interactive components, not to icons. If the design system evolves to expose an icon swatch page, add one `UikitBlock` per icon there — do not scatter icon showcases across other pages. + +--- + +## Things to avoid + +- Adding an icon-library dependency (`@mui/icons-material`, `react-icons`, `lucide-react`, `heroicons`, Font Awesome). +- Barrel exports from `@icons`. +- Extending `IIcon` for one-off props. +- Hardcoded fills (hex, named color, `currentColor`). +- Missing ``. +- Mismatched `width` / `height` for non-square icons. +- Sizing via CSS `transform: scale(...)`. +- Wrapping icons in extra elements for spacing. +- Mixing `<svg>`-level and `<path>`-level `sx` fills in the same icon. +- Emojis in place of icons. +- Inline `<style>` blocks or `<defs>` with gradients unless the design absolutely requires it — flag it and confirm with a designer first. +- Multi-file icon "components" (a folder with `index.tsx` + `Icon.tsx`). One file per icon. diff --git a/.github/instructions/infra.instructions.md b/.github/instructions/infra.instructions.md new file mode 100644 index 0000000..1394bab --- /dev/null +++ b/.github/instructions/infra.instructions.md @@ -0,0 +1,435 @@ +--- +description: Infra conventions — Azure Pipelines templates, Terraform layout & naming, per-environment variables, and local Docker dev. +applyTo: "azure-pipelines.yml,azure-pipeline/**/*.yml,terraform/**/*.{tf,tfvars},frontend/docker-compose.yml,frontend/entrypoint.sh,frontend/example.env" +--- + +# Infrastructure & CI/CD + +Scope: everything that provisions, builds, or deploys the app outside the React source tree. + +- **Azure Pipelines** — root `azure-pipelines.yml` + templates under `azure-pipeline/`. +- **Terraform** — HCL under `terraform/`, per-env tfvars under `terraform/env/`. +- **Local dev container** — `frontend/docker-compose.yml`, `frontend/entrypoint.sh`, `frontend/example.env`. + +Complementary files (do not repeat their content here): +- [typescript.instructions.md](typescript.instructions.md) — where `VITE_*` variables are typed as ambient `__X__` defines in `vite-env.d.ts`. + +This is a **frontend-only, static-site** infra: Vite build → Azure Storage `$web` container → Azure CDN endpoint. There is no backend service to deploy. + +--- + +## Layout at a glance + +``` +azure-pipelines.yml <-- entry point: trigger, name, variable group, stage skeleton +azure-pipeline/ + environments_loop.yml <-- per-env stage template (dev, qa, uat, staging, prod) + build_frontend.yml <-- Node/Yarn build job + terraform_plan.yml <-- Terraform plan job (wraps terraform_steps) + terraform_steps.yml <-- init + validate + plan|apply steps (shared) + deploy_frontend.yml <-- Terraform apply + blob upload + CDN purge (deployment job) + deploy_validation.yml <-- prod-only ManualValidation@0 gate + +terraform/ + provider.tf <-- azurerm provider + backend + main.tf <-- storage account (static site) + CDN profile/endpoint + rules + variables.tf <-- environment, project_short_name + env/ + dev.tfvars <-- environment = "dev" + qa.tfvars + uat.tfvars + staging.tfvars + prod.tfvars + +frontend/ + docker-compose.yml <-- optional local dev in Docker (Node 20.11.1) + entrypoint.sh <-- corepack enable && yarn install && yarn dev + example.env <-- template of the VITE_* variables the app needs +``` + +--- + +## Azure Pipelines conventions + +### The root file stays skeletal + +`azure-pipelines.yml` **only** declares: + +- `trigger:` — branches that fire the pipeline (currently `releases/*`). +- `name:` — build number pattern (`v0.01$(Rev:.rr)`). +- `variables: - group: web-react-skeleton-azure` — the global library. +- Top-level `stages:` — the PR-build stage plus a single `- template: azure-pipeline/environments_loop.yml` invocation. + +Everything else lives in templates. Do not add jobs, scripts, or resource declarations directly to the root file. + +### One template per concern, always parameterized + +Every file in `azure-pipeline/` starts with a `parameters:` block declaring **typed** inputs. Consumers pass values via `template:` + `parameters:`. Reference: [azure-pipeline/build_frontend.yml](azure-pipeline/build_frontend.yml). + +```yaml +parameters: + - name: environment + type: string + - name: commandOptions # optional pass-through + type: string + - name: depends_on # optional dependency list + type: object + default: "" +``` + +Rules: +- **Typed parameters** (`string`, `object`, `boolean`, `number`) — never rely on implicit stringification. +- **Optional parameters have a `default`.** The `depends_on: ""` sentinel is the repo's convention for "no explicit deps"; the consumer template guards with `${{ if ne(parameters.depends_on, '') }}:`. +- **No template reaches directly into `variables['...']` from outside its parameters and library groups.** If a template needs a value, it comes in through `parameters:` or through a library variable it explicitly imports. + +### Naming: job IDs, display names, artifacts + +Follow the existing suffixed pattern so downstream `dependsOn:` references resolve: + +| Kind | Pattern | Example | +| --- | --- | --- | +| Job / deployment ID | `<Verb>_<Thing>_${{ parameters.environment }}` | `Build_Frontend_dev`, `Terraform_Plan_qa`, `Deploy_Frontend_prod` | +| Stage ID | `Deploy_${{ environment }}` | `Deploy_dev` | +| Display name | `<Verb> <Thing> [${{ parameters.environment }}]` | `"Build Frontend [dev]"` | +| Build artifact | `build_frontend_${{ parameters.environment }}` | `build_frontend_prod` | + +Rules: +- **Job ID = display name minus spaces/brackets, prefixed with the verb, environment as suffix.** Downstream `dependsOn:` uses the ID, not the display name. +- **Environment suffix everywhere** — even in single-env stages. Removing it breaks the loop. +- **Never omit the `[env]` display suffix** — the DevOps UI lists dozens of jobs; the suffix is how humans distinguish them. + +### Environments loop is the extension point + +Adding, removing, or reordering environments happens in **one place**: the `environments` parameter default in [azure-pipeline/environments_loop.yml](azure-pipeline/environments_loop.yml). + +```yaml +parameters: + - name: environments + type: object + default: + - dev + - qa + # - uat + # - staging + # - prod +``` + +Rules: +- **Uncomment an environment here to enable it.** No other pipeline file needs a change. +- **A matching `terraform/env/<env>.tfvars` file must exist** before the env is enabled. Missing tfvars fails at plan time. +- **A matching `web-react-skeleton-<env>` library must exist in Azure DevOps** before the env is enabled — the loop imports it via `- group: "web-react-skeleton-${{ environment }}"`. Missing group fails at variable-expansion time. +- **Order in the list = order of deployment**, top to bottom. Keep prod last. +- **Only prod gets `deploy_validation.yml`**, via the inline `${{ if eq(environment, 'prod') }}:` conditional in the loop. Add other manual gates the same way — never fork the loop template. + +### Variable groups & secrets + +Two library groups back the pipeline: + +| Group | Scope | Contents | +| --- | --- | --- | +| `web-react-skeleton-azure` | Global (loaded at root) | `PROJECT_SHORT_NAME`, `ARM_SERVICE_CONNECTION_NAME` | +| `web-react-skeleton-<env>` | Per env (loaded in the loop) | `api-url-<env>`, `ga-tracking-id-<env>` and any per-env secret | + +Rules: +- **Never hardcode env-specific values in yaml.** URLs, tracking IDs, subscription IDs, keys all come from the per-env library. +- **Never inline secrets.** If a secret is added, mark it as secret in the library UI so it doesn't print in logs. +- **Reference library variables as `$(variable-name)`** — the kebab-case names in the library map to the same tokens. +- **`PROJECT_SHORT_NAME` shows up in `-var="project_short_name=$(PROJECT_SHORT_NAME)"`** — Terraform doesn't know about pipeline variables, so bridge them through `commandOptions`. +- **New env-specific value?** Add it to the library **before** the pipeline references it; expanding a missing variable fails the run with a cryptic message. + +### Terraform steps are shared + +`terraform_steps.yml` is the single source of truth for how Terraform runs. Both `terraform_plan.yml` and `deploy_frontend.yml` include it, differing only in `lastCommand: "plan"` vs `"apply"`. + +```yaml +- template: terraform_steps.yml + parameters: + environment: ${{ parameters.environment }} + commandOptions: ${{ parameters.commandOptions }} + lastCommand: "plan" # or "apply" +``` + +Rules: +- **Do not duplicate `TerraformInstaller@1` / `TerraformTaskV4@4` blocks** in other templates. Always go through `terraform_steps.yml`. +- **Terraform version is pinned** to `1.9.2` in the installer step. Bumping it is a deliberate change with a state-migration checklist (see [Terraform](#terraform)). +- **Backend key is per env**: `backendAzureRmKey: "${{ parameters.environment }}.tfstate"`. Never share state across envs. +- **Backend resource group / storage / container are hardcoded** to `rg-global-$(PROJECT_SHORT_NAME)` / `wrstfstorage` / `tfstate`. This mirrors `terraform/provider.tf`; the two must stay in sync. +- **Add new commands (e.g., `destroy`) by extending `lastCommand`**, not by creating a parallel step template. + +### The build job — env vars are the interface to Vite + +[azure-pipeline/build_frontend.yml](azure-pipeline/build_frontend.yml) sets `VITE_*` variables in a `variables:` block, then runs `yarn build`. Vite bakes them into the bundle at build time via `define` (see [frontend/vite.config.ts](frontend/vite.config.ts) and the `__ENV__` / `__API_URL__` / `__VERSION_NUMBER__` / `__GA_TRACKING_ID__` ambient types). + +| Pipeline var | Source | Vite `define` | Consumer in code | +| --- | --- | --- | --- | +| `VITE_VERSION_NUMBER` | `$(Build.BuildNumber)` | `__VERSION_NUMBER__` | `Home.tsx` | +| `VITE_ENV` | pipeline literal (`dev`, `qa`, …) | `__ENV__` | `DebugBanner`, gates | +| `VITE_API_URL` | `$(api-url-<env>)` library | `__API_URL__` | `axiosInstance` | +| `VITE_GA_TRACKING_ID` | `$(ga-tracking-id-<env>)` library | `__GA_TRACKING_ID__` | analytics init | +| `VITE_GENERATE_SOURCEMAP` | pipeline literal (`false`) | Vite build option | build only | + +Rules: +- **One build per environment.** These values are baked in — you cannot promote a `dev` artifact to `prod`. +- **New `VITE_*` variable?** Wire it in *three* places or it silently no-ops: + 1. Add to `frontend/example.env` (for local dev discoverability). + 2. Add to `frontend/vite.config.ts` `define:` block **and** typedef in [frontend/src/vite-env.d.ts](frontend/src/vite-env.d.ts). + 3. Add to `build_frontend.yml`'s `variables:` block, sourcing from the appropriate library entry. +- **Node version is pinned to `22.x`.** Bumping it is a coordinated change with local dev (`docker-compose.yml` uses `node:20.11.1` — keep the intent aligned or note the gap). +- **Yarn stable via corepack** — never call `npm install` in a pipeline step; the repo is Yarn-only. + +### Deployment job specifics + +[azure-pipeline/deploy_frontend.yml](azure-pipeline/deploy_frontend.yml): + +- **`deployment:` (not `job:`)** — enables Azure Environments features (approvals, checks, deployment history). +- **`environment: ${{ parameters.environment }}`** — matches the DevOps Environment name (create it in DevOps before enabling the env). +- **`strategy.runOnce.deploy.steps:`** — the deploy strategy. Do not switch to `canary` / `rolling` without a rewrite; the storage-blob upload is not idempotent across strategies. +- **`pool: vmImage: "windows-latest"`** — required for the CDN Azure CLI commands as configured. Other jobs use `ubuntu-latest`; keep them there. +- **Post-Terraform steps**: empty the `$web` container, upload the build artifact, then purge the CDN. All three must succeed for a deploy to be considered complete. + +Rules: +- **Never skip the CDN purge** — Azure CDN caches aggressively; users see stale JS/CSS for hours otherwise. If you split deploy, keep purge in the same pipeline. +- **Blob upload uses `az storage blob upload-batch`** with the built artifact path, not `azcopy`. Consistency across envs matters. +- **`addSpnToEnvironment: true`** on the AzureCLI task exposes the service principal to the inline script; leave it on for the blob step. + +### Prod validation + +`deploy_validation.yml` is a `pool: server` `ManualValidation@0` job with 60 min timeout. Only prod triggers it. If a future stakeholder wants a QA gate too, extend the loop's `${{ if eq(environment, 'prod') }}:` conditional to `${{ if or(eq(environment, 'prod'), eq(environment, 'staging')) }}:` — do not add a second validation template. + +--- + +## Terraform + +### Provider & backend + +[terraform/provider.tf](terraform/provider.tf) pins: + +- `azurerm` provider version `3.111.0`. +- Backend `azurerm` — RG `rg-global-<project>`, storage `wrstfstorage`, container `tfstate`, key `terraform.tfstate` (overridden per env by the pipeline). +- `features {}` (empty) and `skip_provider_registration = true` — do not enable provider auto-registration; the AzurePipelines doc lists the manual `az provider register` command when a new namespace is needed. + +Rules: +- **Pin provider versions.** Never widen to `~> 3.111` or leave unpinned. Version bumps are their own PR. +- **When bumping the provider**, run `terraform plan` against every env before merging (loop lists them). Some minor bumps require state migration. +- **Backend values in `provider.tf` and `terraform_steps.yml` must match.** Change both together. +- **Resource groups (`rg-<env>-<project>`, `rg-global-<project>`) are NOT managed by Terraform** — they are provisioned out of band and consumed via `data "azurerm_resource_group"`. See [doc/AzurePipelines.md](doc/AzurePipelines.md#resource-groups-for-each-deployment-environment). If a new env is added, its resource group has to be created in Azure first. + +### Variables — small, closed set + +[terraform/variables.tf](terraform/variables.tf) declares two variables: + +- `environment` — supplied by `env/<env>.tfvars`. +- `project_short_name` — supplied by the pipeline via `-var="project_short_name=$(PROJECT_SHORT_NAME)"`. + +Rules: +- **Do not add variables that duplicate what the pipeline can inject.** Keep the interface narrow: one env identifier, one project identifier. +- **`env/<env>.tfvars` today only contains `environment = "<env>"`.** Add real per-env overrides here (e.g., a per-env SKU) rather than baking them into `main.tf` with `count`/`for_each` on the env string. +- **Never `terraform.tfvars` or default-file naming** — always the explicit `-var-file=env/<env>.tfvars` from the loop. + +### Naming convention (Azure resources) + +All resources follow `<abbrev>-<env>-<project_short_name>`, except when Azure's naming rules force a different shape: + +| Resource | Pattern | Notes | +| --- | --- | --- | +| Resource group (data) | `rg-<env>-<project>` | Not managed by TF. | +| Resource group (global, data) | `rg-global-<project>` | Not managed by TF. | +| Storage account | `sa<project><env>` | Storage account names: 3–24 lowercase alphanumeric, no dashes. | +| CDN profile | `cdnp-<env>-<project>` | | +| CDN endpoint | `cdne-<env>-<project>` | | + +Rules: +- **Always interpolate both `${var.environment}` and `${var.project_short_name}`** into resource names. Hardcoded names break the multi-env loop. +- **Storage account names have no dashes and are lowercase.** If `PROJECT_SHORT_NAME` contains dashes or uppercase, sanitize it in the pipeline before it reaches TF — don't add `replace()` inside `main.tf`. +- **Follow the abbreviation set** used by the Azure Cloud Adoption Framework (`rg`, `sa`, `cdnp`, `cdne`, `kv`, `vnet`, `pip`, `nsg`, …). Do not invent new prefixes. + +### Tags — required on every resource + +Every managed resource carries the same tag block: + +```hcl +tags = { + description = "Managed by Terraform" + environment = var.environment +} +``` + +Rules: +- **`description = "Managed by Terraform"` is a boundary marker.** If a resource does not have it, either it isn't managed by TF or it should be — verify before touching it manually in Azure. +- **`environment` tag mirrors the variable.** Do not hardcode; do not omit. +- **Additional tags are welcome** (owner, cost center) but the two above are non-negotiable. + +### CDN rules — SPA-aware, don't reorder blindly + +[terraform/main.tf](terraform/main.tf)'s `azurerm_cdn_endpoint.default` has three delivery rules in a specific `order`: + +1. **`EnforceHTTPS` (order 1)** — redirect HTTP → HTTPS. +2. **`SPArewrite` (order 2)** — rewrite extensionless URLs (`/dashboard`) to `/index.html` so the React router handles them, except `/404`. +3. **`SecurityHeader` (order 3)** — appends `X-Frame-Options: DENY`, `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`. + +Rules: +- **Order matters.** HTTPS enforcement runs first so the rewrite and headers apply to the redirected request. Do not renumber without understanding the effect on precedence. +- **New security header? Extend the `SecurityHeader` rule**, do not add a fourth rule at order 4 with only one header — keeps the ruleset scannable. +- **New route family that must not be rewritten?** Add another `request_uri_condition` to the `SPArewrite` rule (like the existing `/404` exclusion), not a new rule. +- **CSP is not configured today.** Adding one is a conscious choice — Pigment-CSS is compile-time so `'unsafe-inline'` is not needed for styles; still, coordinate with any inline `<script>` usage. + +### Adding a new resource — checklist + +1. Decide whether the resource group exists (data lookup) or needs external creation (extend the doc, not the TF). +2. Name it per the abbreviation table with `${var.environment}` and `${var.project_short_name}`. +3. Add the standard `tags` block. +4. If the resource needs env-specific settings, add them to `env/<env>.tfvars` and declare the variable in `variables.tf`. +5. If any new provider namespace is needed, list the `az provider register` command in [doc/AzurePipelines.md](doc/AzurePipelines.md#possible-issues). +6. Run `terraform plan` locally against `dev.tfvars` before pushing. +7. Merge to a `releases/*` branch — the loop will plan on every enabled env; verify each plan output before approving prod. + +--- + +## Frontend env — the `VITE_*` contract + +[frontend/example.env](frontend/example.env) is the source of truth for what the frontend needs at runtime: + +```env +VITE_PORT=8080 +VITE_GENERATE_SOURCEMAP=true +VITE_ENV=local +VITE_VERSION_NUMBER=v0.0.1 +VITE_API_URL=<API_URL> +VITE_GA_TRACKING_ID=<GA_TRACKING_ID> +VITE_DOCKER=false +``` + +Rules: +- **`VITE_*` prefix is mandatory.** Vite only exposes prefixed vars to the client (and to the `define:` block). +- **Keep `example.env` in sync with `vite.config.ts`.** If you add a `define:` mapping in Vite, add its `VITE_*` source to `example.env` **and** to `build_frontend.yml`. +- **Never commit a real `.env`.** Only `example.env` is tracked. +- **`VITE_DOCKER=true`** flips Vite to bind on `0.0.0.0` (see the Docker section below). Leave it `false` for local non-Docker dev. + +--- + +## Local Docker dev + +Optional path for contributors who don't want Node installed locally. Reference: [frontend/docker-compose.yml](frontend/docker-compose.yml), [frontend/entrypoint.sh](frontend/entrypoint.sh). + +- `docker compose up` from `frontend/` mounts the source tree, runs `entrypoint.sh` (`corepack enable && yarn install && yarn dev`), and exposes port `8000`. +- Node version in the container is `20.11.1` — historical, older than the pipeline's `22.x`. Do not "fix" one without the other; align with the team first. +- Requires `.env` next to `docker-compose.yml` with `VITE_DOCKER=true` so Vite binds to `0.0.0.0` and is reachable from the host. + +Rules: +- **Docker is a dev convenience, not the deploy target.** The production build ships static files; no container runs in prod. +- **Do not add production build stages to `docker-compose.yml`.** New services (mock API, storybook) belong there; production tooling does not. + +--- + +## Common mistakes and how to fix them + +### 1. Hardcoding env-specific values in yaml + +```yaml +# Bad — locks the value to the file +- name: VITE_API_URL + value: "https://api.dev.example.com" +``` + +```yaml +# Good — sourced from the per-env library +- name: VITE_API_URL + value: "$(api-url-${{ parameters.environment }})" +``` + +### 2. Enabling an env without its tfvars / library + +Symptom: run fails with `env/uat.tfvars: no such file` or `variable group 'web-react-skeleton-uat' does not exist`. + +Fix: create `terraform/env/uat.tfvars`, create the DevOps library, then uncomment `uat` in `environments_loop.yml`. + +### 3. Managing resource groups in Terraform + +```hcl +# Bad — the pipeline expects RGs to exist before TF runs +resource "azurerm_resource_group" "rg_env" { + name = "rg-${var.environment}-${var.project_short_name}" + location = "canadacentral" +} +``` + +```hcl +# Good +data "azurerm_resource_group" "rg_env" { + name = "rg-${var.environment}-${var.project_short_name}" +} +``` + +### 4. Adding a `VITE_*` variable only in `build_frontend.yml` + +Symptom: variable set at build time but `undefined` in the bundle. Fix: add the mapping to `vite.config.ts` `define:` and type it in `vite-env.d.ts`. See the three-step wiring in [Env vars are the interface to Vite](#the-build-job--env-vars-are-the-interface-to-vite). + +### 5. Skipping the CDN purge after deploy + +Symptom: stale JS after a release, sometimes for hours. Fix: keep the `az cdn endpoint purge` step in `deploy_frontend.yml`. Never comment it out to "speed up" a deploy. + +### 6. Widening a provider version pin + +```hcl +# Bad — silent minor bumps between deploys +version = "~> 3.111" +``` + +```hcl +# Good — deliberate, reproducible +version = "3.111.0" +``` + +### 7. Missing `tags` on a new resource + +Symptom: audit tools flag "unmanaged" resources; cost reports miss the env dimension. Fix: copy the standard `tags` block onto every `resource "azurerm_..."`. + +### 8. Adding a manual gate as a new template instead of extending the loop + +```yaml +# Bad — a parallel loop file for prod +- template: prod_only_environments_loop.yml +``` + +```yaml +# Good — extend the existing conditional +- ${{ if or(eq(environment, 'prod'), eq(environment, 'staging')) }}: + - template: deploy_validation.yml + parameters: { environment: ${{ environment }}, depends_on: [ Terraform_Plan_${{ environment }} ] } +``` + +### 9. `dependsOn:` referencing a job that isn't in the current env + +Symptom: prod-only `Deploy_Validation_dev` is referenced in a `depends_on` list; the run fails with "unknown dependency" for dev. Fix: gate the entry in the `depends_on` object the same way the job itself is gated (see the `${{ if eq(environment, 'prod') }}:` block in `environments_loop.yml`). + +### 10. Cross-env state sharing + +```yaml +# Bad — every env would overwrite the same state +backendAzureRmKey: "terraform.tfstate" +``` + +```yaml +# Good +backendAzureRmKey: "${{ parameters.environment }}.tfstate" +``` + +--- + +## Things to avoid + +- Editing pipeline yaml to add environment-specific values (use the library). +- Adding logic to `azure-pipelines.yml` — it stays a stage/template skeleton. +- Duplicating `TerraformInstaller` / `TerraformTaskV4` blocks outside `terraform_steps.yml`. +- Committing a real `.env`, tokens, subscription IDs, or connection strings. +- Managing resource groups (or anything else meant to pre-exist) in Terraform. +- Unpinned or widened provider version constraints. +- Skipping tags or omitting the `environment` tag. +- Renaming a job ID without updating every `dependsOn:` that references it. +- Adding a new environment without its tfvars **and** library **and** DevOps Environment. +- Sharing Terraform state files across environments. +- Bypassing Yarn (`npm install`, `pnpm`, ad-hoc `npx`) in a pipeline step. +- Removing the CDN purge step to save minutes. +- Adding a new `VITE_*` variable in only one of the three required places. +- Introducing a new CI/CD system (GitHub Actions, CircleCI) alongside Azure Pipelines — the repo has one. +- Adding a production Dockerfile — the app is deployed as static files, not as a container. diff --git a/.github/instructions/mui-and-uikit.instructions.md b/.github/instructions/mui-and-uikit.instructions.md new file mode 100644 index 0000000..9b5ad68 --- /dev/null +++ b/.github/instructions/mui-and-uikit.instructions.md @@ -0,0 +1,266 @@ +--- +description: MUI (Material UI v6 + Pigment-CSS) wrapping conventions and how to register components in the UiKit page. +applyTo: "frontend/src/app/components/**/*.tsx,frontend/src/app/pages/uikit/**/*.tsx,frontend/src/themes/**/*.ts,frontend/vite.config.ts" +--- + +# MUI & UiKit + +Scope: how to wrap MUI primitives into repo-consistent components, how the Pigment-CSS integration works, and how new components get registered on the UiKit page. + +Complementary files (do not repeat their content here): +- [styling.instructions.md](styling.instructions.md) — utility classes, theme tokens, `styled()` / `sx` rules, CSS Modules. +- [react.instructions.md](react.instructions.md) — component shape (`function` keyword, default export, props interface). +- [typescript.instructions.md](typescript.instructions.md) — naming (`I` prefix, aliases). +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — where wrappers live. + +--- + +## Stack summary + +| Concern | Package | Notes | +|---|---|---| +| Component library | `@mui/material` v6 | Standard MUI primitives. | +| Styling engine | `@mui/material-pigment-css` + `@pigment-css/vite-plugin` | Compile-time CSS extraction. `styled()`, `css()`, and `sx` come from here, **not** from `@mui/material/styles`. | +| Icons library | `@mui/icons-material` | Available but the repo prefers custom SVG icons under `@icons` (see `icons.instructions.md`). | +| Theme | `frontend/src/themes/theme.ts` | Built with `cssVariables: true`, extended via [frontend/src/material-ui-pigment-css.d.ts](frontend/src/material-ui-pigment-css.d.ts). | +| Pigment-CSS wiring | [frontend/vite.config.ts](frontend/vite.config.ts) | `pigment({ theme, transformLibraries: ["@mui/material"] })`. Adding another MUI-family lib means extending `transformLibraries`. | + +The Pigment integration is the reason `sx` is cheap in this project (compiled to static CSS). Do not "upgrade" to `@emotion/react` or `@mui/styles` — that would break the build. + +--- + +## Wrap MUI primitives; don't use them raw + +Every MUI primitive used in the app is wrapped in a repo component under `@components/<name>/<Name>.tsx`. New code imports the wrapper, not the MUI original. + +```tsx +// Bad — MUI primitive imported directly in a page/container +import { Button } from "@mui/material"; + +// Good — repo wrapper +import Button from "@components/button/Button"; +``` + +The **only** MUI primitives imported directly are the ones without a wrapper today: +- `<Typography>` (`@mui/material/Typography`) — style is fully covered by variants. +- `<Grid>` / `<Grid2>` (`@mui/material/Grid2`) — layout primitive. + +Anything else new needs a wrapper before use. If you find yourself importing from `@mui/material` in a page or form, stop and create the wrapper first. + +### Why wrap? + +- Single place to enforce a repo-wide default (border-radius, transition, icon slot). +- Insulates against MUI upgrades that change internal class names or slots. +- Keeps API surface small and typed to the repo's needs (e.g. `TextField.onChange` returning `value: string`). + +--- + +## The wrapper pattern + +Every wrapper follows the same shape. Study the canonical example and copy it. + +```tsx +// Canonical wrapper pattern — wraps a MUI primitive with a styled() default +import { ButtonProps, Button as MuiButton } from "@mui/material"; +import { styled } from "@mui/material-pigment-css"; + +const StyledMuiButton = styled(MuiButton)(({ theme }) => ({ + borderRadius: theme.customProperties.borderRadius.xs, +})); + +interface IButton extends ButtonProps { + target?: string; +} + +export default function Button({ children, ...props }: IButton) { + return <StyledMuiButton {...props}>{children}</StyledMuiButton>; +} +``` + +Checklist for a new wrapper: + +1. **Import both the primitive and its props type** aliased: `import { XProps, X as MuiX } from "@mui/material";`. +2. **Import `styled` from `@mui/material-pigment-css`** — not from `@mui/material/styles`. +3. **Name the styled node `StyledMuiX`** and declare it above the exported component. +4. **Props interface `IX extends XProps`**. Use `Omit<XProps, "field">` when you replace an existing prop (see `ITextField` renaming `onChange`). +5. **Default export**, `function` keyword, file name matches (`X.tsx`). +6. **Style through `theme.customProperties.*` / `theme.palette.*` / `theme.breakpoints.up(...)`** — never hardcoded values. See `styling.instructions.md`. +7. **No new business logic** in the wrapper. It's a styling + defaults layer. +8. **Set sensible defaults via destructuring**, not `defaultProps`. +9. **Register it in the UiKit page** (see below). + +### Patterns + +**Passing through when only defaults are needed** — do it via destructuring, then spread: + +```tsx +// Wrapping with fixed defaults +export default function Accordion({ children, ...props }: AccordionProps) { + return ( + <StyledMuiAccordion {...props} disableGutters elevation={0} square> + {children} + </StyledMuiAccordion> + ); +} +``` + +**Replacing a prop's signature** — `Omit` the original, redeclare: + +```tsx +// Repackaging onChange as (value: string) => void +interface ITextField extends Omit<TextFieldProps, "onChange"> { + onChange: (value: string) => void; +} +``` + +**Providing a default JSX slot** — destructure with a default expression: + +```tsx +// Default icon slot for an accordion summary +export default function AccordionSummary({ + children, + expandIcon = <CaretIcon width={16} height={16} />, + ...props +}: AccordionSummaryProps) { /* ... */ } +``` + +**Forwarding a ref** — only when the wrapper genuinely needs to pass a DOM ref through (e.g. a `<Slide>` used as a transition component): + +```tsx +// Only add forwardRef when a parent (MUI dialog, transition group, popper) needs the ref +const Slide = forwardRef(function Slide( + { direction = "up", timeout = 500, ...props }: SlideProps, + ref: Ref<unknown>, +) { + return <MuiSlide ref={ref} direction={direction} timeout={timeout} {...props} />; +}); +export default Slide; +``` + +`forwardRef` is used **only** when a parent (MUI dialog, transition group, popper, …) needs the ref. Do not add it prophylactically. + +--- + +## Targeting MUI internals — last resort + +When wrapping, prefer changing behaviour through **props and named slots**. Reach for `& .MuiX-y` internal-class selectors only when there's no slot for what you need. These selectors break silently across MUI major versions. + +```tsx +// Acceptable — accessing named parts through the documented slot classes +const StyledMuiAccordionSummary = styled(MuiAccordionSummary)(({ theme }) => ({ + flexDirection: "row-reverse", + "& .MuiAccordionSummary-expandIconWrapper.Mui-expanded": { + transform: "rotate(90deg)", + }, + "& .MuiAccordionSummary-content": { + marginLeft: theme.spacing(1), + alignItems: "center", + }, +})); + +// Preferred if a prop / slot exists — e.g. `disableGutters`, `elevation`, `square` +<StyledMuiAccordion {...props} disableGutters elevation={0} square /> +``` + +If you must target internals, add a one-line comment noting **why** (no prop covers it) so a future reader knows why the fragile selector exists. + +--- + +## `css()` alongside `styled()` + +Use `css()` from `@mui/material-pigment-css` when you need a **class token** rather than a wrapped component — typically for `react-transition-group` `classNames` slots. Same theme access, same compile-time extraction. + +```tsx +// Class-token pattern for transition slots +import { css } from "@mui/material-pigment-css"; + +const enterActive = css(({ theme }) => ({ + opacity: 1, + transition: `opacity ${theme.transitions.duration.standard}ms ${theme.transitions.easing.easeIn}`, +})); + +<CSSTransition classNames={{ enter: classes["enter"], enterActive, /* ... */ }} /> +``` + +Do not build class strings by hand for transition libraries — use `css()`. + +--- + +## Theme extension (custom slots on `Theme`) + +When you introduce a new custom token (a `zIndex` layer, a new `customProperties` bucket, a palette shade beyond MUI defaults), extend the theme types in one place: + +- Add the value in [frontend/src/themes/variables.ts](frontend/src/themes/variables.ts) or `palette.ts`. +- Augment the type in [frontend/src/material-ui-pigment-css.d.ts](frontend/src/material-ui-pigment-css.d.ts) — this file already augments `Theme`, `ThemeOptions`, `ZIndex`, `CustomSpacing`, `CustomBorderRadius`. Mirror the existing pattern: + +```ts +declare module "@mui/material/styles" { + interface CustomSpacing { + /* add new keys here */ + } + interface Theme { + /* add new top-level custom buckets here */ + } + interface ThemeOptions { + /* mirror as Partial<> */ + } +} +``` + +Full token/theme rules live in [styling.instructions.md](styling.instructions.md#theme-is-the-single-source-of-truth). + +--- + +## Register new components in the UiKit page + +The UiKit page is a live style guide. **Every new wrapper under `@components/*` gets an entry in the UiKit page (`@pages/uikit`).** + +Anatomy of an entry: + +```tsx +<UikitBlock + id="fieldhelpertext" // slug — used for nav anchor + title="FieldHelperText.tsx" // shown as the block heading + codeBlock={`<TextField label="Username" /> +<FieldHelperText + fieldNames="username" + helperText="Name must be minimum 1 character" +/>`} // optional — shows a copyable code snippet +> + <TextField label={t("login__username")} /> + <FieldHelperText + fieldNames="username" + helperText="Name must be minimum 1 character" + /> +</UikitBlock> +``` + +Rules: +- **`id`** is a stable slug (lowercase, no spaces). The `UikitNav` in the page scans `.uikit-block` DOM nodes on mount and builds the side nav from their first child's text — the title. +- **`title`** matches the component file name (e.g. `Button.tsx`, `FieldHelperText.tsx`) so a reader can grep from the guide to the source. +- **`codeBlock`** is a template literal showing the smallest useful usage. Omit only when the component isn't reasonably code-demonstrable (e.g. a layout container). +- Show meaningful variants (empty / with data / error / disabled) inside a single `UikitBlock` or across siblings, matching what's already there for `FieldHelperText`. +- The helper primitives (`UikitBlock`, `UikitColor`, `UikitNav`) live under `@components/uikit/*` — do not add new UiKit helpers under `@components/` roots; keep them namespaced under `@components/uikit/`. + +If you skip UiKit registration, the design system silently drifts — flag it as a missing step in reviews. + +--- + +## Icons + +- Do not import `@mui/icons-material` in new code without a wrapper — the repo prefers custom SVG icons under `@icons/*` implementing the `IIcon` contract. +- Full icon rules live in `icons.instructions.md`. + +--- + +## Things to avoid + +- Importing directly from `@mui/material` outside `@components/*` (with the whitelisted exceptions: `Typography`, `Grid`/`Grid2`). +- Importing `styled` / `css` from `@mui/material/styles` or `@emotion/styled` — always from `@mui/material-pigment-css`. +- Recreating a wrapper that already exists (grep `@components/*` before you write a new one). +- Adding runtime CSS-in-JS libraries (`emotion`, `styled-components`, `stitches`) — Pigment-CSS is the only styling runtime. +- Overriding MUI theme behaviour globally in `theme.ts` to fix a one-off — express it in the component wrapper instead (when the app grows, global overrides tend to cause hard-to-diagnose bugs). +- Putting business logic (data fetching, navigation, store access) inside a wrapper — wrappers are presentational (see [components-vs-containers.instructions.md](components-vs-containers.instructions.md)). +- Shipping a new component without a UiKit entry. +- `defaultProps` — use destructuring defaults. +- Extending `theme` types anywhere other than `material-ui-pigment-css.d.ts`. diff --git a/.github/instructions/react.instructions.md b/.github/instructions/react.instructions.md new file mode 100644 index 0000000..83a4964 --- /dev/null +++ b/.github/instructions/react.instructions.md @@ -0,0 +1,347 @@ +--- +description: React conventions for the frontend (components, hooks, JSX, composition). +applyTo: "frontend/src/**/*.tsx" +--- + +# React conventions + +Scope: all `.tsx` files under `frontend/src`. + +Complementary files (do not repeat their content here): +- [typescript.instructions.md](typescript.instructions.md) — typing, naming, path aliases. +- [styling.instructions.md](styling.instructions.md) — SCSS / BEM / utility classes / theme tokens. +- [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md) — MUI wrapping and uikit registration. +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — where each component / container / form lives. +- [routing-and-auth.instructions.md](routing-and-auth.instructions.md) — routes, `withAuth`, and `EPermission`. +- [forms.instructions.md](forms.instructions.md) — the full form recipe (this file only summarizes it). +- [state-stores.instructions.md](state-stores.instructions.md) — Zustand for global state. +- [i18n.instructions.md](i18n.instructions.md) — the "translate in parent, pass a string down" rule. + +The project uses React 18 with `<StrictMode>`, `react-router-dom` v6, `react-i18next`, and MUI v6 with Pigment-CSS. + +--- + +## Components are always functional + +- **Only functional components.** No class components. +- Use the `function` keyword (not an arrow function) for the component itself. This gives cleaner stack traces and matches every existing component in the repo. +- One default export per file, and the file name matches it (`Button.tsx` → `export default function Button`). +- Private helper components live above the exported one in the same file; do not export them. + +```tsx +// Canonical wrapper: styled() + interface extending MUI props +import { ButtonProps, Button as MuiButton } from "@mui/material"; +import { styled } from "@mui/material-pigment-css"; + +const StyledMuiButton = styled(MuiButton)(({ theme }) => ({ + borderRadius: theme.customProperties.borderRadius.xs, +})); + +interface IButton extends ButtonProps { + target?: string; +} + +export default function Button({ children, ...props }: IButton) { + return <StyledMuiButton {...props}>{children}</StyledMuiButton>; +} +``` + +Avoid: +- `React.FC` / `React.FunctionComponent` — type props inline via `IProps`. +- `defaultProps` — use destructuring defaults instead. +- `forwardRef` unless you actually need to expose a DOM ref to a parent. + +--- + +## Props + +- Every component has a single props interface named `I<Component>` (e.g. `IButton`, `ILoginForm`). See [typescript.instructions.md](typescript.instructions.md) for the naming rules. +- **Extend the underlying MUI props interface** when wrapping MUI, and `Omit` what you replace: + +```tsx +// Wrapper repackaging onChange as (value: string) => void +interface ITextField extends Omit<TextFieldProps, "onChange"> { + onChange: (value: string) => void; +} +``` + +- Type `children` as `ReactNode`: + +```tsx +export default function AuthProvider({ + children, + permission, +}: { + children: ReactNode; + permission: EPermission; +}) { /* ... */ } +``` + +- Type state setter props as `Dispatch<SetStateAction<T>>` (matches the shape returned by `useState`): + +```tsx +interface ILoginForm { + setIsLoading: Dispatch<SetStateAction<boolean>>; +} +``` + +- **Callback props are named after the event they handle** — `onClickSubmit`, `onChangeLanguage`, `onBlur`, not `handleX`. +- Provide defaults in destructuring, not inside the body: + +```tsx +export default function AddRounded({ + className, + width = 24, + height = 24, + alt = "Add Rounded", +}: IIcon) { /* ... */ } +``` + +- **Translated strings are passed as strings, never as translation keys.** The parent calls `t()`; the child renders `props.label` as-is. + +```tsx +// Bad — child translates +<Button>{t(props.label)}</Button> + +// Good — parent translates, child just renders +<Button label={t("login__sign_in")} /> +``` + +--- + +## Hooks + +- Follow the rules of hooks: top level only, no conditionals, no loops. +- Group hooks at the top of the component body — see the file order section below. +- Provide **exhaustive deps** for `useEffect` / `useCallback` / `useMemo`. Do not silence the ESLint rule. + +### `useState` + +- Type the state explicitly when it isn't trivially inferred. +- Setter names are `set<Variable>`: + +```tsx +const [isLoading, setIsLoading] = useState<boolean>(false); +const [formErrors, setFormErrors] = useState<ValidationError[]>([]); +``` + +- Use the functional updater form when the new value depends on the previous: + +```tsx +setLoginForm((prevState) => ({ ...prevState, username: value })); +``` + +### `useCallback` and `useMemo` + +- Wrap handlers with `useCallback` when they are: + - passed to memoized children, or + - listed in another hook's dep array. +- `useMemo` for genuinely expensive computations or for stable object identity (e.g. the flat-mapped router config): + +```tsx +const routesObj = useMemo( + () => routes.flatMap(/* ... */), + [t, localePath], +); +``` + +- Do not memoize primitives or trivially-cheap objects; you're paying for the memoization without benefit. + +### `useEffect` + +- **Each effect does one thing.** If you find yourself branching on unrelated conditions, split it into two effects. +- Cleanup subscriptions, timers, and aborts: + +```tsx +useEffect(() => { + const controller = new AbortController(); + fetchData({ signal: controller.signal }); + return () => controller.abort(); +}, []); +``` + +- Prefer event handlers over effects for actions that fire in response to a user event. Effects are for synchronizing with external systems (URL, localStorage, third-party libs, network on mount). +- The container that reads a token then hydrates a store is the canonical "run once we know the auth state" reference — see `routing-and-auth.instructions.md`. + +--- + +## Global state vs local state + +- Local UI state → `useState` inside the component. +- Cross-page or cross-component state (current user, feature flags, cart, etc.) → Zustand store under `@stores`. See `state-stores.instructions.md` for shape. +- Reach into the store with the hook, destructuring only what you use so the component re-renders on the right slice: + +```tsx +const { user, setUser } = useUserStore(); +``` + +- Do not lift state into a context "just in case" — reach for Zustand instead. Context is fine for values that never change (theme, i18n instance). + +--- + +## Composition patterns used in the repo + +### Layout as a namespaced object + +Layouts can be exported as a single object with multiple variants: + +```tsx +const Layout = { Container, Auth }; +export default Layout; + +// usage +<Layout.Container>{children}</Layout.Container> +<Layout.Auth>{children}</Layout.Auth> +``` + +Follow this pattern when a component has multiple mutually-exclusive variants that share styling but not markup. + +### Lazy-loaded pages + +Pages are `React.lazy`-imported from their `*.route.tsx` file. Do not import a page component directly at module top level. + +```tsx +// A route file lazy-imports the page (or its withAuth wrapper) +const homeRoute: IRoute = { + name: "home__page_title", + component: lazy(() => import("./withAuthHome")), + paths: { en: `/${en.locale__key}/${en.routes__home}`, fr: /* ... */ }, +}; +``` + +The lazy import must point at the `withAuth<Page>.tsx` wrapper when the page is protected; see the routing instructions for the full pattern. + +### Higher-order components + +The only HOC in the repo is `withAuth`. When you need to wrap a page with auth: + +```tsx +// One-line composition, no JSX or hooks in the wrapper file +const withAuthHome = withAuth(Home, EPermission.HomeRead); +export default withAuthHome; +``` + +Do not invent new HOCs for concerns that a hook can express — a custom hook is almost always the better tool. + +--- + +## JSX rules + +- **Never** use `dangerouslySetInnerHTML`. +- No inline `style={{ ... }}`. Use utility classes → `styled()` → `sx` (in that order). Full rules and when `sx` is allowed live in `styling.instructions.md`. +- `className` composes utility classes from `src/styles` with a BEM class for the component: + +```tsx +<div className="admin flex-col gap-xs">...</div> +``` + +Use `classnames` (already a dep) when composing conditionally: + +```tsx +import cx from "classnames"; +<div className={cx("card", { "card--selected": isSelected })} /> +``` + +- Add `key` on every list item, and use a **stable id**, never the array index (except for truly static lists). +- Fragments over wrapper `div`s when no styling is needed: `<>...</>`. +- Booleans in conditionals: use `condition && <X />` only when `condition` is a boolean. Convert numbers with `!!count && <X />` to avoid rendering `0`. + +--- + +## Skeletons + +A skeleton must occupy the same container as the eventual content so the layout doesn't shift. + +```tsx +// Good — same container, just swap the child +<div className="my-container"> + {isFetchingData ? ( + <Skeleton className="my-container__item" animation="wave" /> + ) : ( + <CustomImage className="my-container__item" src="..." /> + )} +</div> + +// Bad — different container in each branch +{isFetchingData ? ( + <div className="my-container"><Skeleton ... /></div> +) : ( + <div className="my-container"><CustomImage ... /></div> +)} +``` + +--- + +## Forms + +Forms live under `@forms/<domain>/<formName>/`. Every form: + +1. Controlled `useState` object for the form values. +2. A Yup schema colocated in `<formName>.schema.ts`. +3. Validate on submit with `schema.validateSync(values, { abortEarly: false })` inside a `try`/`catch`. +4. Track a `<name>Validated` boolean so re-validation on `onBlur` only fires after the first submit. +5. Store `ValidationError[]` from `error.inner` and pass it to `FieldHelperText` per field. +6. Show async loading with the `Loading` component driven by a parent-owned `isLoading` state passed as `setIsLoading`. + +Full patterns and schema conventions live in `forms.instructions.md`. + +--- + +## File content order + +Inside a `.tsx` file, keep this order: + +1. Imports (external → aliased → relative → styles). +2. Interfaces / types / enums. +3. Module-level constants. +4. Helper functions. +5. Styled components / private components. +6. The exported component (usually one). + +Inside a component body: + +1. Hooks (`useTranslation`, `useNavigate`, store hooks, `useState`, `useRef`, custom hooks). +2. Derived variables. +3. Local functions (`useCallback` wrappers). +4. `useEffect` blocks. +5. `return` with JSX. + +Sort alphabetically within a group when it does not hurt readability. + +--- + +## Common third-party libraries + +Standard tools already installed — reach for these before inventing: + +| Concern | Library | Notes | +|---|---|---| +| Routing | `react-router-dom` v6 | `useNavigate`, `useParams`, `<Navigate />`, `createBrowserRouter`. | +| i18n | `react-i18next` | `useTranslation()`. See `i18n.instructions.md` for key rules. | +| HTTP | `axios` via `@services/axiosInstance` | Never import `axios` directly in a component; use a service. | +| Toasts | `react-toastify` | Give each toast a stable `toastId` to prevent duplicates. | +| Head tags | `react-helmet-async` | Already wired at the router level; use for per-page overrides. | +| Analytics | `react-ga4` | Only initialize behind cookie consent (see `App.tsx`). | +| Dates | `dayjs` | Never `new Date()` for formatting. | +| Query strings | `qs` | Already used in `axiosInstance` param serializer. | +| Class composition | `classnames` | Prefer over template strings for conditional classes. | +| Transitions | `react-transition-group` | Used by the `Slide` component. | + +Do not add new state / form / data-fetching libraries without discussion — Zustand + native fetch-via-axios + Yup covers the current scope. + +--- + +## Things to avoid + +- Class components. +- `React.FC`, `defaultProps`, `PropTypes`. +- `dangerouslySetInnerHTML`. +- Inline styles or `style={{ ... }}` prop (banned; MUI Pigment-CSS `sx` is the escape hatch — see `styling.instructions.md`). +- Context for mutable app state (use Zustand). +- Effects that re-implement event handlers. +- `useEffect` without a dep array unless you truly mean "every render". +- Ignoring the exhaustive-deps ESLint rule. +- Mutating state directly — always return a new object/array from setters. +- Using the array index as a `key` for anything the user can reorder. +- Duplicating translation calls in children — translate once in the parent. +- Business logic in components when it belongs in a service. diff --git a/.github/instructions/routing-and-auth.instructions.md b/.github/instructions/routing-and-auth.instructions.md new file mode 100644 index 0000000..dedb166 --- /dev/null +++ b/.github/instructions/routing-and-auth.instructions.md @@ -0,0 +1,390 @@ +--- +description: React Router v6 conventions, per-page route + withAuth files, and the EPermission-based auth flow. +applyTo: "frontend/src/app/{routes,pages,hocs}/**/*.{ts,tsx}" +--- + +# Routing & Auth + +Scope: the router, `routes.ts`, per-page `*.route.tsx` + `withAuth<Page>.tsx` files, the `withAuth` HOC, and how pages talk to `AuthProvider` / `EPermission`. + +Complementary files (do not repeat their content here): +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — the container-vs-page distinction and its table. +- [react.instructions.md](react.instructions.md) — component shape, hooks, `useCallback`. +- [typescript.instructions.md](typescript.instructions.md) — naming (`I` prefix, `E` prefix), path aliases. +- [services-api.instructions.md](services-api.instructions.md) — how services (e.g. `getMe`) are structured. +- [i18n.instructions.md](i18n.instructions.md) — locale keys (`locale__key`, `routes__<page>`) come from the Google Sheet via `sheet2i18n`. +- [state-stores.instructions.md](state-stores.instructions.md) — how `AuthProvider` populates `useUserStore` on boot. + +Router library: `react-router-dom` v6 (`createBrowserRouter`, `useNavigate`, `useParams`, `<Navigate />`). + +--- + +## Big picture + +Three folders cooperate: + +| Folder | Owns | +|---|---| +| `@routes` | Route registry (`routes.ts`), the `Router` component, the `IRoute` type, and the `findRoute` helper. | +| `@pages/<name>` | One page = **three files**: `<Name>.tsx`, `<name>.route.tsx`, and (for protected pages) `withAuth<Name>.tsx`. | +| `@hocs` | The `withAuth` HOC that wraps a page with `AuthProvider`. | +| `@containers/authProvider` | The `AuthProvider` that does the actual auth check + user fetch. | +| `@enums/EPermission` | The permission enum consumed by `withAuth`. | + +URLs are **locale-prefixed** — every page has one path per language, derived from locale JSON keys. Language switch is a URL rewrite via `findRoute`. + +--- + +## Adding a new page — the full recipe + +Every new page follows the same three-file (or two-file, if public) structure. Copy the dashboard pattern. + +### 1. Page component — `<Name>.tsx` + +Presentational. Renders inside a `Layout.*`. See [react.instructions.md](react.instructions.md) for component shape. + +```tsx +// pages/<name>/<Name>.tsx +import Layout from "@components/layout/Layout"; + +function Home() { + return <Layout.Container>DASHBOARD</Layout.Container>; +} +export default Home; +``` + +### 2. Route object — `<name>.route.tsx` + +The route is a plain `IRoute` object. **Always lazy-import** the page component — never a top-level import. + +```tsx +// pages/<name>/<name>.route.tsx +import en from "@assets/locales/en.json"; +import fr from "@assets/locales/fr.json"; +import { IRoute } from "@routes/interfaces/IRoute"; +import { lazy } from "react"; + +const dashboardRoute: IRoute = { + name: "dashboard__page_title", // i18n key + component: lazy(() => import("./withAuthDashboard")), // protected: point at the HOC wrapper + paths: { + en: `/${en.locale__key}/${en.routes__dashboard}`, + fr: `/${fr.locale__key}/${fr.routes__dashboard}`, + }, +}; + +export default dashboardRoute; +``` + +Rules: +- **File name** is lowerCamelCase with a `.route.tsx` suffix; the exported constant is `<name>Route`. +- **`name`** is a translation key, not a raw string. It should end in `__page_title` — used as the browser tab title (`Router.tsx` composes `${t(route.name)} - ${t("routes__page_title")}`). +- **`paths.en` and `paths.fr`** are template literals built from locale JSON: `` `/${en.locale__key}/${en.routes__<name>}` ``. Both keys must exist in `en.json` and `fr.json`. See `i18n.instructions.md` — the keys are managed by `sheet2i18n`, not by hand. +- **`component`** points at the `withAuth<Name>` wrapper for protected pages, or at the raw page for public ones (see the login route for reference). +- **Dynamic segments** use `:param` in the path and add a `getPath(locale, id)` helper on the `IRoute` for building URLs elsewhere: + ```tsx + paths: { + en: `/${en.locale__key}/${en.routes__project}/:id`, + fr: `/${fr.locale__key}/${fr.routes__project}/:id`, + }, + getPath: (locale, id) => `/${locale}/project/${id}`, + ``` + +### 3. Auth wrapper — `withAuth<Name>.tsx` (protected pages only) + +Wraps the page with `withAuth` and a permission. This is the file the route's `lazy(...)` points at. + +```tsx +// pages/<name>/withAuth<Name>.tsx +import EPermission from "@enums/EPermission"; +import withAuth from "@hocs/withAuth"; +import Dashboard from "@pages/dashbaord/Dashboard"; + +const withAuthDashboard = withAuth(Dashboard, EPermission.DashboardRead); + +export default withAuthDashboard; +``` + +Rules: +- **File name** is lowerCamelCase `withAuth<Name>.tsx`; the exported const has the same casing. +- The wrapper is a pure composition — do not add JSX, hooks, or logic here. +- **Do not** wrap public pages (like `Login`). Route them directly at the page component. + +### 4. Register in `routes.ts` + +```ts +// routes/routes.ts +import dashboardRoute from "@pages/dashbaord/dashboard.route"; +import homeRoute from "@pages/home/home.route"; +import loginRoute from "@pages/login/login.route"; +import uikitRoute from "@pages/uikit/uikit.route"; + +const routes = [homeRoute, loginRoute, dashboardRoute]; + +if (__ENV__ !== "prod") { + routes.push(uikitRoute); +} + +export default routes; +``` + +Rules: +- Import the route object (never the page). +- **Env-gate** dev-only routes with `if (__ENV__ !== "prod")` (or the appropriate check). `__ENV__` is a Vite `define` — see `typescript.instructions.md` for ambient globals. +- **Do not** register `notFoundRoute` here. It is imported by `Router.tsx` and mounted separately at the end of the route list. + +### 5. If the page is protected, add the permission + +Extend `EPermission` — the enum is the source of truth for permission names. Match the convention `<Page>Read` / `<Page>Write` etc. + +```ts +// enums/EPermission.ts +const enum EPermission { + HomeRead = "HomeRead", + DashboardRead = "DashboardRead", + UikitRead = "UikitRead", + // add here +} +``` + +> Note: `AuthProvider` currently checks that the user is authenticated but **does not yet enforce the `permission` value** (there is a `// TODO: validate permission` in the container). Add the permission anyway — the wiring is in place; the check will be implemented later. + +--- + +## Navigation + +### Never hardcode URLs. Use the route object. + +```tsx +// Bad +navigate(`/en/dashboard`); +navigate("/login"); + +// Good +navigate(homeRoute.paths[t("locale__key")]); +navigate(loginRoute.paths[t("locale__key")], { replace: true }); +``` + +Why: paths are locale-prefixed and come from translations. Hardcoding breaks the second you add a locale or rename a segment. + +For dynamic segments, use the `getPath` helper: + +```tsx +navigate(projectRoute.getPath(t("locale__key"), projectId)); +``` + +### Redirect after auth flows uses `{ replace: true }` + +Redirects that shouldn't leave a history entry (login, session expired, not-found → home) pass `{ replace: true }`: + +```tsx +navigate(loginRoute.paths[t("locale__key")], { replace: true }); +``` + +Match the existing sites (`AuthProvider`, `NotFound`) — user-driven navigation does not use `replace`. + +### Locale switch — `findRoute` + +To toggle the current URL to the other locale (keeping dynamic segments intact), use `findRoute` from `@routes/findRoute`: + +```tsx +// language toggle handler +const onChangeLanguage = useCallback(() => { + navigate(findRoute(location.pathname, t("locale__switch_key"))); + i18n.changeLanguage(t("locale__switch_key")); +}, [navigate, t]); +``` + +Rules: +- Both the `navigate(...)` and the `i18n.changeLanguage(...)` calls are required — one changes the URL, the other changes the i18n runtime language. +- `findRoute` returns the input path unchanged if no route matches — treat that as a safe default. + +--- + +## The `Router` component + +The `Router` component (`@routes/Router`) is the only place that constructs the router. It: + +1. Reads the current locale from `t("locale__key")`. +2. Flat-maps `routes` into one entry per locale-path. +3. Wraps each route element with a `<Helmet>` (tab title) and mounts `<DebugBanner />` alongside. +4. Prepends `/` → `<Navigate to={homeRoute.paths[localePath]} />`. +5. Appends `notFoundRoute` at `*` as the catch-all. +6. Memoizes the router with `useMemo([routesObj, localePath])`. + +Do not modify `Router.tsx` for a new page — the loop over `routes` handles it. Touch `Router.tsx` only when: + +- Adding a new **global element** rendered on every route (currently `DebugBanner`, `Helmet`). +- Changing the root redirect target. +- Changing the not-found fallback. + +`App.tsx` wires the surrounding providers (`<Suspense>`, `<CookieConsent>`, `<ToastContainer>`, then `<Router />`) — do not add global concerns to `Router.tsx`; add them in `App.tsx`. + +--- + +## `IRoute` interface + +Defined at `@routes/interfaces/IRoute`: + +```ts +export type IPaths = { + [key: string]: string; // required for dynamic locale indexing + en: string; + fr: string; +}; + +export interface IRoute { + name: string; // i18n key + component: LazyExoticComponent<() => JSX.Element>; // always lazy + paths: IPaths; + getPath?: (locale: string, id: string) => string; // for dynamic segments +} +``` + +Rules: +- Do not export a route with a non-lazy component. `Router.tsx` and `<Suspense>` depend on the lazy shape. +- Add a new locale by extending `IPaths` with the new key **and** adding a matching value in every `paths` object. Skip either and TypeScript won't help you — the index signature makes any string key valid at runtime. + +--- + +## `notFoundRoute` — special + +```tsx +// notFound.route.tsx +const notFoundRoute: IRoute = { + name: "not_found__page_title", + component: lazy(() => import("./NotFound")), + paths: { en: "*", fr: "*" }, +}; +``` + +- Path is `*` in every locale (react-router-dom's wildcard). +- Not registered in `routes.ts` — imported and mounted separately in `Router.tsx`. +- No `withAuth` — the not-found page is public. +- Uses `navigate(..., { replace: true })` when redirecting home so the broken URL is not in history. + +--- + +## Authentication flow + +The full chain: + +``` +Route withAuth<Page> withAuth HOC AuthProvider Page +──────────────────────────────────────────────────────────────────────────────────────────── +route.component = lazy(withAuthDashboard) + │ + └─ withAuth(Dashboard, EPermission.DashboardRead) + │ + └─ <AuthProvider permission={...}> + ├─ read ACCESS_TOKEN from localStorage + ├─ if missing → navigate(loginRoute, replace: true) + ├─ if user unset → call getMe() → setUser(store) + ├─ on 401 → toast "expired session" + remove token + navigate(loginRoute) + └─ render <Dashboard /> once user is set +``` + +Rules that fall out of this chain: + +- **A protected page never checks auth itself.** Assume `useUserStore().user` is defined by the time it renders — that's the contract `AuthProvider` enforces. +- **Do not** replicate the token/`getMe`/redirect logic in pages, containers, or services. Anything that needs "on-mount auth check" is a bug — add the missing route to `routes.ts` with the wrapper, or use the existing `withAuth`. +- **Public pages** (`Login`, `NotFound`) must handle the "user might not exist" case themselves. Do not import `AuthProvider` in public pages. +- **Login writes tokens**, then navigates to the home route: + ```tsx + localStorage.setItem(ACCESS_TOKEN, data.token); + localStorage.setItem(REFRESH_TOKEN, data.refreshToken); + setUser(data); + navigate(homeRoute.paths[t("locale__key")]); + ``` +- **Logout is the mirror**: remove both tokens, `setUser(undefined)`, navigate to `loginRoute` (typically from the home page's logout handler). +- **Token constants** (`ACCESS_TOKEN`, `REFRESH_TOKEN`) live in `@shared/constants`. Do not stringly-type storage keys. + +### The `withAuth` HOC + +```ts +// hocs/withAuth.tsx +export default function withAuth( + WrappedComponent: ComponentType, + permission: EPermission, +) { + return function WrappedWithAuth() { + return ( + <AuthProvider permission={permission}> + <WrappedComponent /> + </AuthProvider> + ); + }; +} +``` + +Keep it this small. If you find yourself adding logic to `withAuth`, it belongs in `AuthProvider` instead. + +--- + +## Common mistakes and how to fix them + +### 1. Hardcoded URL + +```tsx +// Bad +<Link to="/en/dashboard">Go to dashboard</Link> +``` + +```tsx +// Good +<Link to={dashboardRoute.paths[t("locale__key")]}>Go to dashboard</Link> +``` + +### 2. Direct import in the route file + +```tsx +// Bad — non-lazy, and doesn't wrap with auth +import Dashboard from "./Dashboard"; +const dashboardRoute: IRoute = { component: Dashboard, /* ... */ }; +``` + +```tsx +// Good — lazy import of the withAuth wrapper +const dashboardRoute: IRoute = { + component: lazy(() => import("./withAuthDashboard")), + /* ... */ +}; +``` + +### 3. Auth check inside a page + +```tsx +// Bad — page duplicating AuthProvider's job +useEffect(() => { + if (!user) navigate(loginRoute.paths[t("locale__key")]); +}, [user]); +``` + +```tsx +// Good — trust the wrapper; assume user exists +const { user } = useUserStore(); +return <p>Hello, {user!.firstName}</p>; // or non-null via ! given the invariant +``` + +### 4. New page not added to `routes.ts` + +Symptom: the URL 404s even though the file exists. Fix: import and push into `routes` (env-gated if dev-only). + +### 5. Adding a locale-specific URL segment without updating locales + +The `en.json` / `fr.json` keys `routes__<page>` are the source of segment text. Adding a new page requires a new key **in both locale files** with matching semantics. See `i18n.instructions.md` — do this through `sheet2i18n`, not by hand-editing the JSON. + +--- + +## Things to avoid + +- Importing `react-router-dom` types (`RouteObject`, `RouteProps`, …) into new code — use the repo's `IRoute` instead. +- Non-lazy `component:` on an `IRoute`. +- Hardcoded URL strings anywhere (`"/login"`, `"/en/home"`, …). +- Manual `useEffect` auth checks in pages. +- Reading tokens (`localStorage.getItem("ACCESS_TOKEN")`) directly with a stringly-typed key — use `ACCESS_TOKEN` / `REFRESH_TOKEN` from `@shared/constants`. +- Registering `notFoundRoute` in `routes.ts`. +- Adding hooks or JSX inside `withAuth<Page>.tsx` — it stays a one-line composition. +- Creating a new HOC for cross-cutting concerns when a hook would do — `withAuth` is the only sanctioned HOC. +- Passing raw permission strings (`"DashboardRead"`) to `withAuth` — always use `EPermission.*`. +- Reading the current locale from anywhere other than `t("locale__key")`. diff --git a/.github/instructions/services-api.instructions.md b/.github/instructions/services-api.instructions.md new file mode 100644 index 0000000..7bddf56 --- /dev/null +++ b/.github/instructions/services-api.instructions.md @@ -0,0 +1,320 @@ +--- +description: Axios service conventions — folder layout, axiosInstance, function shape, interfaces, and error handling boundaries. +applyTo: "frontend/src/app/services/**/*.{ts,tsx}" +--- + +# Services & API + +Scope: everything under `@services/*` — the shared `axiosInstance`, per-domain service files, and their interfaces. + +Complementary files (do not repeat their content here): +- [typescript.instructions.md](typescript.instructions.md) — `I` prefix, one interface per file, path aliases, `interface` vs `type`. +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — who is allowed to call services (containers, pages, forms — **not** presentational components). +- [routing-and-auth.instructions.md](routing-and-auth.instructions.md) — how `AuthProvider` bootstraps `getMe`; token constants in `@shared/constants`. +- [i18n.instructions.md](i18n.instructions.md) — how the caller maps errors to `toast.error(t("errors__..."))` with a stable `toastId`. +- [react.instructions.md](react.instructions.md) — where the `.then / .catch / .finally` chain lives (in `useCallback` inside a container/form). + +HTTP client: `axios` v1. Query-string serializer: `qs`. Base URL: `__API_URL__` (Vite `define`). + +--- + +## Folder layout + +Every backend domain becomes one folder under `@services/<domain>/`: + +``` +services/ + axiosInstance.ts <-- shared instance (do not duplicate) + <domain>/ + <domain>Service.ts <-- exports named async functions + interfaces/ + I<Model>.ts <-- one interface per file, default export + I<Request>.ts + I<Response>.ts +``` + +Rules: +- **One folder per domain**, not per endpoint. `authService.ts` owns every `/auth/*` call; `userService.ts` owns every `/user/*` call. Do not create `loginService.ts`. +- **File name = `<domain>Service.ts`** (camelCase `<domain>` + literal `Service`). Do not name it `api.ts`, `endpoints.ts`, or `<domain>.ts`. +- **Interfaces live under `interfaces/`** inside the owning domain folder. Import cross-domain interfaces via the alias (`@services/users/interfaces/IUser`) — see the login endpoint returning `IUser`. +- **Domain ownership follows the response model.** `IUser` lives under `services/users/interfaces/` because the user *is* what `/user/*` operates on, even though `postLogin` also returns one. + +--- + +## The service function shape + +Every function follows the same template: + +```ts +// services/<domain>/<domain>Service.ts +import ILogin from "@services/auth/interfaces/ILogin"; +import axiosInstance from "@services/axiosInstance"; +import IUser from "@services/users/interfaces/IUser"; +import { AxiosResponse, CancelToken } from "axios"; + +const AUTH_PREFIX = "/auth"; +const POST_LOGIN = `${AUTH_PREFIX}/login`; + +export async function postLogin( + login: ILogin, + cancelToken?: CancelToken, +): Promise<AxiosResponse<IUser>> { + return await axiosInstance.post(POST_LOGIN, login, { + cancelToken, + }); +} +``` + +Checklist for a new service function: + +1. **URL constants at the top of the file.** `SCREAMING_SNAKE_CASE`, one per endpoint, composed from a shared `<DOMAIN>_PREFIX`. +2. **Named export**, `export async function`. Never `default export` for services; never anonymous arrow functions. +3. **Function name = HTTP verb + Resource**, camelCase: `getMe`, `postLogin`, `putProfile`, `patchProject`, `deleteSession`. The verb must match the axios method used inside. +4. **Signature order**: `(body/payload, ...queryOrPathArgs, cancelToken?: CancelToken)`. For `GET` requests without a body, path/query args come first. +5. **Return type is `Promise<AxiosResponse<IModel>>`** — return the full `AxiosResponse`, not `.data`. Callers destructure (`.then(({ data }) => ...)`). +6. **Use `axiosInstance` from `@services/axiosInstance`.** Never import `axios` directly in a service file — you'd bypass the auth token, base URL, and locale header. +7. **Pass `cancelToken` through** to the axios options object even when unused today. The signature keeps parity with existing services. +8. **Do not `try/catch` inside the service.** Let errors propagate — the caller (container / form) handles them with `.catch` + `toast.error`. +9. **Do not import** from `@stores`, `@containers`, `@pages`, `@components`, `@hocs`, `react-i18next`, `react-router-dom`, or React itself. Services are pure I/O. + +### Examples of each HTTP verb + +```ts +// GET with path parameter +const USER_PREFIX = "/user"; +const GET_USER_BY_ID = (id: number) => `${USER_PREFIX}/${id}`; + +export async function getUserById( + id: number, + cancelToken?: CancelToken, +): Promise<AxiosResponse<IUser>> { + return await axiosInstance.get(GET_USER_BY_ID(id), { cancelToken }); +} + +// GET with query params — pass via `params`; qs handles serialization +export async function getUsers( + filters: IUserFilters, + cancelToken?: CancelToken, +): Promise<AxiosResponse<IUser[]>> { + return await axiosInstance.get(`${USER_PREFIX}`, { params: filters, cancelToken }); +} + +// PUT with body + id +const PUT_USER = (id: number) => `${USER_PREFIX}/${id}`; + +export async function putUser( + id: number, + user: IUserUpdate, + cancelToken?: CancelToken, +): Promise<AxiosResponse<IUser>> { + return await axiosInstance.put(PUT_USER(id), user, { cancelToken }); +} + +// DELETE +export async function deleteUser( + id: number, + cancelToken?: CancelToken, +): Promise<AxiosResponse<void>> { + return await axiosInstance.delete(GET_USER_BY_ID(id), { cancelToken }); +} +``` + +Notes: +- Endpoints that take a path parameter are exported as **arrow-function constants** returning the URL, not string templates inlined at the call site. +- The `params` config accepts a plain object; the `paramsSerializer` in `axiosInstance` handles arrays as `?ids=1&ids=2` (`arrayFormat: "repeat"`). Do not build query strings by hand. + +--- + +## Interfaces for request/response models + +- One interface per file, `I<Name>.ts`, `export default interface I<Name>`. See [typescript.instructions.md](typescript.instructions.md#interfaces-vs-type) for the full rule. +- **Request bodies** get their own interface (`ILogin`, `IUserUpdate`) — do not inline object types in the service signature. +- **Response models** get their own interface (`IUser`). If a response is a bare array, the service returns `AxiosResponse<IModel[]>`; do not create `IUsersResponse` just to wrap it. +- **Optional fields** on shared models use `?` and reflect the API reality (e.g. `IUser.token?: string` because `/auth/login` returns it but `/user/me` does not). +- Do not import `AxiosResponse` into an interface file — the axios wrapper belongs at the service signature, not in the model. + +--- + +## The shared `axiosInstance` + +The file at `@services/axiosInstance` is the **single** axios instance for the app. Do not create a second one for "special cases." + +What it does: + +| Concern | Where | +|---|---| +| `baseURL` | `__API_URL__` (Vite `define` in [frontend/vite.config.ts](frontend/vite.config.ts), typed in `vite-env.d.ts`). | +| Default `Content-Type` | `application/json`. | +| Query-string serializer | `qs.stringify(params, { arrayFormat: "repeat" })` — arrays become repeated params. | +| Request interceptor: `Accept-Language` | Read from the URL path prefix (`location.pathname.substring(1, 3)` or `"en"`). Keeps API responses in the current locale. | +| Request interceptor: `Authorization` | If `localStorage.getItem(ACCESS_TOKEN)` is set, adds `Bearer ${token}`. | + +Rules: +- **Never** `import axios from "axios"` in a service or component. Always `import axiosInstance from "@services/axiosInstance"`. +- Do not add a second instance for "unauthenticated" calls — the interceptor already skips the header when the token is missing. +- If you need a new default header, add it to the create config or the interceptor. Do not sprinkle headers across every call site. +- Do not touch the `paramsSerializer`; it is public contract for all `params` usage. +- Token constants (`ACCESS_TOKEN`, `REFRESH_TOKEN`) come from `@shared/constants`. Do not stringly-type them. + +--- + +## Where services get called + +Services are called from **containers, pages, or forms** — never from a presentational component under `@components/*`. See [components-vs-containers.instructions.md](components-vs-containers.instructions.md). + +The canonical call site pattern: + +```tsx +setIsLoading(true); +postLogin(loginForm) + .then(({ data }) => { + if (data.token && data.refreshToken) { + localStorage.setItem(ACCESS_TOKEN, data.token); + localStorage.setItem(REFRESH_TOKEN, data.refreshToken); + } + setUser(data); + navigate(homeRoute.paths[t("locale__key")]); + }) + .catch((error) => { + if (error.response?.data?.message === "Invalid credentials") { + toast.error(t("errors__invalid_credentials"), { toastId: "invalid-credentials" }); + } else { + toast.error(t("errors__generic"), { toastId: "generic" }); + } + }) + .finally(() => { + setIsLoading(false); + }); +``` + +Rules that fall out of this pattern: + +- **Wrap the call in a `useCallback`** (typically the `onSubmit` / `onClick` handler) — not in `useEffect` unless the fetch is genuinely on-mount data (`AuthProvider.getMe` is the reference for on-mount). +- **Loading state is owned by the caller** — the container/form flips its own `isLoading`. Services do not know about loading. +- **Destructure `.data` in `.then`** — services return the full `AxiosResponse`. +- **Handle known errors first, generic last.** Match on `error.response?.status` or `error.response?.data?.message`. Fall through to `toast.error(t("errors__generic"), { toastId: "generic" })`. +- **`toast.error` always with a stable `toastId`** — see [i18n.instructions.md](i18n.instructions.md#error-and-toast-copy). +- **`401` triggers logout in `AuthProvider`** — a plain page or form should not implement session-expiry redirect itself. See [routing-and-auth.instructions.md](routing-and-auth.instructions.md#authentication-flow). + +--- + +## Cancellation + +The current repo passes `cancelToken?: CancelToken` through every service signature but no call site actually supplies one today. Preserve the parameter for consistency with existing services — it costs nothing and makes future cancellation straightforward. + +```tsx +// If a caller ever needs to abort: +const source = axios.CancelToken.source(); +useEffect(() => { + getUsers(filters, source.token).then(/* ... */).catch(/* ignore cancel */); + return () => source.cancel(); +}, [filters]); +``` + +Axios's `CancelToken` is deprecated in favour of `AbortController.signal`, but the repo has not migrated. Do not introduce `signal` on new services — stay consistent with the existing `CancelToken` shape until the whole layer is migrated in one pass. + +--- + +## Common mistakes and how to fix them + +### 1. Bare `axios` in a service + +```ts +// Bad — bypasses baseURL, auth token, locale header, qs serializer +import axios from "axios"; +export async function getMe() { + return axios.get("/user/me"); +} +``` + +```ts +// Good +import axiosInstance from "@services/axiosInstance"; +export async function getMe(cancelToken?: CancelToken): Promise<AxiosResponse<IUser>> { + return await axiosInstance.get(GET_ME, { cancelToken }); +} +``` + +### 2. Returning `.data` from the service + +```ts +// Bad — loses status/headers, breaks the AxiosResponse<T> contract +export async function getMe(): Promise<IUser> { + const { data } = await axiosInstance.get(GET_ME); + return data; +} +``` + +Callers rely on destructuring `.data` in `.then(({ data }) => ...)`. Keep the boundary consistent. + +### 3. `try/catch` inside the service + +```ts +// Bad — swallows the error and forces every caller to check a sentinel +export async function getMe() { + try { + return await axiosInstance.get(GET_ME); + } catch (e) { + toast.error("Something went wrong"); + return null; + } +} +``` + +Services throw; the container/form decides UX. + +### 4. Service reaching into stores + +```ts +// Bad — services must not know about zustand, router, or i18n +import { useUserStore } from "@stores/userStore"; +export async function refreshMe() { + const { data } = await axiosInstance.get(GET_ME); + useUserStore.getState().setUser(data); // no + return data; +} +``` + +The caller writes to the store. Services stay pure I/O. + +### 5. Inlined URL strings + +```ts +// Bad +return axiosInstance.get(`/user/${id}`, { cancelToken }); + +// Good +const USER_PREFIX = "/user"; +const GET_USER_BY_ID = (id: number) => `${USER_PREFIX}/${id}`; +return axiosInstance.get(GET_USER_BY_ID(id), { cancelToken }); +``` + +Constants keep endpoints greppable and prevent typos across sibling functions. + +### 6. Building query strings manually + +```ts +// Bad +return axiosInstance.get(`/users?ids=${ids.join(",")}&active=${active}`); + +// Good — let the serializer format arrays as ?ids=1&ids=2 +return axiosInstance.get(USER_PREFIX, { params: { ids, active }, cancelToken }); +``` + +--- + +## Things to avoid + +- `import axios from "axios"` outside `axiosInstance.ts`. +- Creating a second axios instance. +- `default export` on a service function. +- Returning anything other than `AxiosResponse<T>` from a service. +- `try/catch` inside services (the caller catches). +- Services calling stores, i18n, router, toasts, or DOM APIs. +- Inlined endpoint URLs at the call site (use the constants at the top of the service file). +- Manual query-string construction (use `params`). +- Directly reading `localStorage` in a service (the interceptor handles auth; other storage reads belong in the caller / container). +- Naming a service `api.ts`, `endpoints.ts`, or `<domain>.ts` — always `<domain>Service.ts`. +- Wrapping bare arrays in `IWrapperResponse` interfaces for no reason. +- Introducing `AbortController.signal` on new services while the rest use `CancelToken` — migrate the whole layer or none. +- Adding request logging, retry, or debounce inside a service. If globally desired, add it in the interceptor. If per-call, own it in the caller. diff --git a/.github/instructions/state-stores.instructions.md b/.github/instructions/state-stores.instructions.md new file mode 100644 index 0000000..e7a1c70 --- /dev/null +++ b/.github/instructions/state-stores.instructions.md @@ -0,0 +1,352 @@ +--- +description: Zustand store conventions — when to promote state to a store, file layout, setter shape, consumption, and hydration/reset patterns. +applyTo: "frontend/src/app/stores/**/*.ts" +--- + +# State stores (Zustand) + +Scope: everything under `@stores/*`. The repo uses **Zustand v4** for cross-component state. Local component state stays in `useState`; only state that must be shared across unrelated subtrees gets a store. + +Complementary files (do not repeat their content here): +- [react.instructions.md](react.instructions.md) — `useState` vs "global state" decision (this file expands the "global state" side). +- [typescript.instructions.md](typescript.instructions.md) — `I` prefix, `use*` naming, path aliases. +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — which layers may import `@stores/*` (containers, forms, pages — not shared components). +- [routing-and-auth.instructions.md](routing-and-auth.instructions.md) — how `AuthProvider` populates `useUserStore` on boot. +- [services-api.instructions.md](services-api.instructions.md) — service call pattern used when hydrating a store. + +Library: `zustand@^4` only. No other state managers (no Redux, MobX, Jotai, Recoil, Context-as-store). + +--- + +## When to create a store + +Add a store **only** when both are true: + +1. The state is read or written by **two or more sibling subtrees** that don't share a natural parent (or the natural parent is `App`). +2. Passing the state as props would require crossing more than one intermediary that doesn't otherwise use it (i.e., real prop-drilling — not "one hop"). + +If either is false, keep it as `useState` in the closest common parent. Two counter-examples: + +| Symptom | Better home | +| --- | --- | +| Form-level values, validation, submit flag | Local `useState` in the form (see [forms.instructions.md](forms.instructions.md)). | +| Dialog open/close consumed by only one page | Local `useState` on that page. | +| Server data used on one page | Local `useState` + fetch in `useEffect`, or fetch inside the container that owns the page. | +| A value that is genuinely global (current user, theme, feature flags, cart) | Store. | + +Rule of thumb: **one store per domain**, not one god-store. Today only one domain qualifies (the current user). + +--- + +## File & folder layout + +``` +stores/ + <domain>Store.ts <-- camelCase, one file per domain +``` + +Rules: +- **Flat folder.** No subfolders per store, no `interfaces/` subfolder. Store, its shape, and its setters all live in one file. +- **File name = `<domain>Store.ts`** (camelCase). The named export is `use<Domain>Store`. +- **No barrel `index.ts`**. Consumers import the store file directly: `import { useUserStore } from "@stores/userStore"`. +- **One `create<...>()` call per file.** Never two stores in the same file. +- **Path alias `@stores`** — never relative imports like `../../stores/userStore`. + +--- + +## Anatomy of a store + +```ts +import IUser from "@services/users/interfaces/IUser"; +import { create } from "zustand"; + +interface IUserStore { + user?: IUser; + setUser: (user?: IUser) => void; +} + +export const useUserStore = create<IUserStore>((set) => ({ + user: undefined, + setUser: (user) => set({ user }), +})); +``` + +Rules in order of what they encode: + +1. **Import the domain type from its owning service**, not a duplicate. `IUser` lives under `@services/users/interfaces/IUser` because it is a server model (see [services-api.instructions.md](services-api.instructions.md#interfaces)). +2. **Named import `create` from `zustand`** — never `create from "zustand/vanilla"` unless you're writing a non-React store (the repo has no such case). +3. **Interface `I<Name>Store`** inside the file, **not exported**. Consumers rely on the hook's return type, not the interface. +4. **State + setters live on the same interface**, in that order (values first, setters after). Setters are `readonly`-ish by convention — do not add `let` assignments. +5. **Optional values use `?`** with a default of `undefined`. Never `null`. +6. **Setter naming**: `set<Field>` for single-field setters, `reset<Domain>` for reset-to-initial, `<verb><Field>` for action verbs (`toggleSidebar`, `incrementCount`). Match the field name casing exactly (`user` → `setUser`). +7. **Setter signature mirrors the field type** — `setUser: (user?: IUser) => void`. If the field is optional, the setter argument is optional. +8. **Setter implementation via `set({ field })` shorthand** when the update is a straight replacement. Use the functional form `set((state) => ({ field: state.field + 1 }))` only when the new value depends on the previous one. +9. **Named export**, `use<Domain>Store`. Never default-export a store (see [typescript.instructions.md](typescript.instructions.md#exports)). +10. **No initial-state constants defined outside the `create` call** unless reused by a reset action (see [Resetting](#resetting-on-logout-or-context-switch)). + +### With an action that depends on previous state + +```ts +interface ICartStore { + items: ICartItem[]; + addItem: (item: ICartItem) => void; + removeItem: (id: string) => void; + clear: () => void; +} + +const initialItems: ICartItem[] = []; + +export const useCartStore = create<ICartStore>((set) => ({ + items: initialItems, + addItem: (item) => set((state) => ({ items: [...state.items, item] })), + removeItem: (id) => + set((state) => ({ items: state.items.filter((item) => item.id !== id) })), + clear: () => set({ items: initialItems }), +})); +``` + +Rules: +- **Functional `set((state) => ...)`** whenever the next state depends on the previous. Never read from the outer hook (`useCartStore.getState()`) inside a component-facing setter — Zustand already gives you `state` in the callback. +- **Return only the fields that change** from the callback. `set` shallow-merges; no need to spread the whole state. +- **Reset via a stable initial constant** so `clear` and the initializer share one source of truth. + +--- + +## Consuming a store + +Standard pattern — destructure the fields you need: + +```tsx +const { user, setUser } = useUserStore(); +``` + +Rules: +- **Destructure only what you use** — TS strictness (`noUnusedLocals`) will yell if you pull `setUser` without using it. +- **Setters are stable references** (Zustand keeps them across renders). You can safely list them in `useCallback` dep arrays. +- **Do not `useUserStore.getState()`** inside a component. That bypasses React's subscription and won't re-render on change. `getState()` is only for imperative, non-React code (e.g., an Axios interceptor). +- **Do not spread the store**: `const store = useUserStore()` then `store.user`. That subscribes to every field and re-renders on any change. + +### Selectors — when to reach for them + +The full-destructure pattern above **subscribes to the whole store**. For the current stores (small, few fields), that's fine. Promote to selectors when either is true: + +- The store has grown past ~5 fields, and consumers read only one or two. +- A component re-renders too often because an unrelated field mutates. + +Selector form: + +```tsx +// Read one field, re-render only when it changes +const user = useUserStore((state) => state.user); + +// Setters are already stable — pull them the same way +const setUser = useUserStore((state) => state.setUser); +``` + +Rules: +- **One selector per read.** Do not build an object in the selector (`(state) => ({ a: state.a, b: state.b })`) without `useShallow` from `zustand/react/shallow` — otherwise you re-render every time. +- **Never call hooks inside a selector.** Selectors are plain functions. +- **Do not memoize selectors with `useCallback`.** Zustand handles referential equality itself; a fresh selector closure per render is fine. + +--- + +## Hydration on app boot + +Server-backed state (e.g., the current user) is loaded once in a container, not on every consumer: + +- **`AuthProvider`** is the single place that populates `useUserStore` — it calls `getMe()` when there's a token but no user, then `setUser(data)`. +- Downstream pages read `user` and assume it's defined. They must sit **inside** a page wrapped with `withAuth` (see [routing-and-auth.instructions.md](routing-and-auth.instructions.md#the-page-recipe)) so the `AuthProvider` gate has already resolved. +- Do not fetch `getMe()` (or any hydration call) from a leaf component "just in case" — duplicate calls cause double-renders and race conditions. + +If you add a new store fed by a service, follow the same pattern: + +1. Create the store with the field `undefined` by default. +2. Add hydration to the appropriate container (or a new one) inside its `useEffect`. +3. Consumers assume the field is populated by the time they render. + +--- + +## Resetting on logout or context switch + +Manual reset today — call the setter with `undefined` / initial: + +```ts +// logout handler +localStorage.removeItem(ACCESS_TOKEN); +localStorage.removeItem(REFRESH_TOKEN); +setUser(undefined); +navigate(loginRoute.paths[t("locale__key")]); +``` + +Rules: +- **Every store touched by an authenticated user must be reset on logout.** Add its reset call to the logout handler. +- **Do not rely on page reload** to clear state. The SPA doesn't reload on logout in this repo — the router just navigates. +- If a store has many fields, expose a `reset<Domain>()` action rather than requiring the caller to unset each field. + +--- + +## Persistence — not used, and when to add it + +The repo **does not** use `zustand/middleware`'s `persist` today. Tokens go to `localStorage` directly; user data is re-hydrated via `getMe()` on each page load through `AuthProvider`. + +Prefer that pattern for new stores when: +- The data is small and cheap to re-fetch. +- Freshness matters more than boot latency. + +Reach for `persist` only when: +- The data is genuinely client-owned (draft form values across reloads, UI prefs, feature-flag overrides). +- Re-fetching would cost too much or is impossible offline. + +If you add it, put persisted stores in their own file and be explicit about `partialize` — never persist server-model fields. + +--- + +## Middleware — not used + +No `devtools`, `immer`, `subscribeWithSelector`, or `combine` middleware today. Justification: the stores are small and setters read plainly. + +If you need `immer` for deeply nested updates, that is a **signal** your store has grown too big — split it into two stores first. If it truly can't be split (e.g., a graph editor), then add `immer`. Keep any middleware addition to the store that needs it — do not blanket-apply. + +--- + +## Accessing store state from non-React code + +For code that runs outside React (Axios interceptors, one-shot event listeners, module-level init), use the static API: + +```ts +// Axios interceptor — outside a component +import { useUserStore } from "@stores/userStore"; + +const currentUserId = useUserStore.getState().user?.id; +useUserStore.setState({ user: undefined }); +``` + +Rules: +- **`getState()` reads a snapshot** — it does not subscribe. Use only where you truly can't be in a component. +- **`setState()` merges shallowly** — same behavior as the callback form. +- **Do not use `getState()` in a component** — you'll miss re-renders. + +Today no non-React code in the repo does this. Follow the pattern above if the need arises. + +--- + +## Common mistakes and how to fix them + +### 1. New `useState` when a store already covers it + +```tsx +// Bad — Home.tsx already has `useUserStore`, this shadows it +const [me, setMe] = useState<IUser | undefined>(); +useEffect(() => { + getMe().then(({ data }) => setMe(data)); +}, []); +``` + +```tsx +// Good — the store is the source of truth +const { user } = useUserStore(); +``` + +### 2. Reading with `useUserStore.getState()` inside a component + +```tsx +// Bad — no subscription, stale reads +const user = useUserStore.getState().user; +``` + +```tsx +// Good +const { user } = useUserStore(); +// or, if only one field +const user = useUserStore((state) => state.user); +``` + +### 3. Setter that spreads the whole state + +```ts +// Bad — `set` already shallow-merges +setUser: (user) => set((state) => ({ ...state, user })), +``` + +```ts +// Good +setUser: (user) => set({ user }), +``` + +### 4. Building an object in a selector without `useShallow` + +```tsx +// Bad — re-renders every render +const { user, setUser } = useUserStore((state) => ({ + user: state.user, + setUser: state.setUser, +})); +``` + +```tsx +// Good — either full destructure (small store) … +const { user, setUser } = useUserStore(); +// … or two selectors (large store) +const user = useUserStore((state) => state.user); +const setUser = useUserStore((state) => state.setUser); +``` + +### 5. Forgetting to reset a store on logout + +Symptom: the next user briefly sees the previous user's name / cart / preferences. Fix: call the store's reset action in `onLogout` alongside token removal. + +### 6. Cross-store setters inside `create` + +```ts +// Bad — cart store reaches into user store +export const useCartStore = create<ICartStore>((set) => ({ + items: [], + clearOnLogout: () => { + useUserStore.setState({ user: undefined }); // don't + set({ items: [] }); + }, +})); +``` + +Composition belongs in the **caller** (the logout handler), not inside a store. Stores stay independent. + +### 7. Interface exported from the store file + +```ts +// Bad — encourages typing consumers against IUserStore +export interface IUserStore { /* ... */ } +``` + +```ts +// Good — interface stays file-local +interface IUserStore { /* ... */ } +``` + +### 8. Default-exporting the store + +```ts +// Bad +export default useUserStore; +``` + +```ts +// Good — named export; consistent import ergonomics +export const useUserStore = create<IUserStore>(/* ... */); +``` + +--- + +## Things to avoid + +- Multiple stores in one file. +- Cross-store imports inside `create` (compose in the caller). +- `getState()` / `setState()` inside React components. +- Object-returning selectors without `useShallow`. +- Storing form state, dialog open/close, or single-page counters in a store. +- Persisting server models via `zustand/middleware/persist` (re-hydrate through the owning service instead). +- Adding `immer` to make deep updates easier (split the store instead). +- Introducing Context / Redux / Jotai / Recoil for global state. +- Re-declaring server-model interfaces inside the store file — import them from `@services/*/interfaces/*`. +- Relying on page reload to reset stores on logout — explicit resets only. +- Exporting the `I<Name>Store` interface. +- Default-exporting the hook. diff --git a/.github/instructions/styling.instructions.md b/.github/instructions/styling.instructions.md new file mode 100644 index 0000000..75c3654 --- /dev/null +++ b/.github/instructions/styling.instructions.md @@ -0,0 +1,319 @@ +--- +description: Styling conventions — utility classes, theme tokens, styled components, SCSS/CSS Modules, and what NOT to do. +applyTo: "frontend/src/**/*.{scss,tsx},frontend/src/themes/**/*.ts" +--- + +# Styling + +Scope: SCSS files, CSS Modules, styled components, and the `className` decisions inside `.tsx`. Also covers `frontend/src/themes/` because it defines the tokens everything else consumes. + +Complementary files (do not repeat their content here): +- [react.instructions.md](react.instructions.md) — JSX rules (this file expands on the "no inline styles" rule). +- [typescript.instructions.md](typescript.instructions.md) — path aliases, naming. +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — where styling code lives. +- [mui-and-uikit.instructions.md](mui-and-uikit.instructions.md) — the pattern for **wrapping** an MUI component (this file covers styling **inside** those wrappers). +- [icons.instructions.md](icons.instructions.md) — SVG-icon-specific styling rules (theme fills via `sx`). + +--- + +## The five layers, in priority order + +When adding style, pick the first tool from this list that fits. Do not skip layers. + +1. **Utility classes** from [frontend/src/styles/_globals.scss](frontend/src/styles/_globals.scss) and [frontend/src/styles/_export.scss](frontend/src/styles/_export.scss) — one-off spacing, flex, alignment. +2. **`<Typography>` variants** from the theme — anything text-related. +3. **Pigment-CSS `styled()`** with `theme.customProperties.*` / `theme.palette.*` — wrapping a MUI component, custom layout that reads theme tokens. +4. **Pigment-CSS `sx` prop** (`sx={(theme) => ({ ... })}`) — one-off theme-value access on a plain element (see the `sx` section below). +5. **CSS Modules (`*.module.css`)** — container-scoped visuals with multiple related classes. +6. **Colocated component SCSS (`<component>.scss`) with BEM** — only when none of the above apply. Rare in the current codebase. + +**Do not** reach for inline `style={{...}}` or a new global class. + +--- + +## Utility classes (the first stop) + +Prefer utility classes over any other tool for spacing, flexbox, and simple positioning. They are auto-generated from the design tokens, so they always match the theme. + +### Spacing (auto-generated, see `_export.scss`) + +Scale keys: `a` (`auto`), `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` — resolved to CSS variables under `--mui-customProperties-spacing-*`. + +| Pattern | Example | Effect | +|---|---|---| +| `gap-<key>` | `gap-md` | `gap` | +| `m<dir>-<key>` | `m-xs`, `mx-md`, `mt-lg`, `ml-a` | `margin` (`dir` ∈ `""`, `x`, `y`, `l`, `r`, `t`, `b`) | +| `p<dir>-<key>` | `p-md`, `px-lg`, `pt-xs` | `padding` (same directions) | +| `<position>-<key>` | `top-xs`, `bottom-md`, `left-a`, `right-lg` | absolute-position offsets | + +Reference direction map: `""` = all, `x` = left+right, `y` = top+bottom, `l/r/t/b` = single side. + +### Layout & alignment (from `_globals.scss`) + +| Class | Effect | +|---|---| +| `flex` | `display: flex` | +| `flex-column` | `display: flex; flex-direction: column` | +| `flex-1` | `flex: 1` | +| `flex-grow` | `flex-grow: 1` | +| `align-center` | `align-items: center` | +| `justify-center` / `justify-between` / `justify-start` / `justify-end` | `justify-content: ...` | +| `text-center` | `text-align: center` | +| `position-absolute` / `-fixed` / `-relative` / `-sticky` | `position: ...` | + +### Composing utilities + +```tsx +// Good — utility classes for spacing/layout, semantic class for the component +<div className="admin flex-column gap-xs">...</div> + +// Good — conditional composition with classnames +import cx from "classnames"; +<div className={cx("card", "p-md", { "mt-lg": hasHeader })} /> +``` + +Do not re-implement utility styles in a component `.scss` (e.g. `.card { display: flex; }` when `flex` already exists). + +### Why `#body .foo`? (gotcha) + +The generator emits utility selectors prefixed with `#body` (see `_export.scss`) to win specificity against MUI's own class rules. Consequence: a plain `.card { margin: 0; }` in a component `.scss` **cannot** override a utility class applied to the same element. If you need to override, either drop the utility class or increase specificity on your rule. + +--- + +## Theme is the single source of truth + +The MUI theme is created with `cssVariables: true` (see [frontend/src/themes/theme.ts](frontend/src/themes/theme.ts)), so every value is available both as `theme.<path>` in TS and as `var(--mui-<path>)` in CSS/SCSS. + +### Token map + +| Concern | Access in `styled()` / TS | Access in CSS / SCSS | Defined in | +|---|---|---|---| +| Spacing scale (`a`/`xxs`…`xxl`) | `theme.customProperties.spacing.md` | `var(--mui-customProperties-spacing-md)` | `themes/variables.ts` | +| MUI numeric spacing | `theme.spacing(2)` | — | (0.25rem × n) | +| Border radius (`xs`/`sm`/`md`/`lg`) | `theme.customProperties.borderRadius.md` | `var(--mui-customProperties-borderRadius-md)` | `themes/variables.ts` | +| Palette | `theme.palette.primary.main` | `var(--mui-palette-primary-main)` | `themes/palette.ts` | +| Typography | use `<Typography variant="...">` | — | `themes/typography.ts` | +| Breakpoints | `theme.breakpoints.up("md")` | `@include media-min(md)` | `themes/variables.ts` | +| z-index | `theme.zIndex.<name>` | `var(--mui-zIndex-<name>)` | `themes/variables.ts` | + +**Never hardcode a color, spacing, radius, or z-index.** If a value isn't in the theme, add it to the theme (see next section) rather than inlining it. There is one `// TODO: get this from theme` in `Layout.tsx` for a background color — that is a bug to fix, not a pattern to copy. + +### Adding a new token + +1. Add it to the appropriate map in [frontend/src/themes/variables.ts](frontend/src/themes/variables.ts) (`spacingValues`, `borderRadius`, `zIndex`) or the palette in `palette.ts`. +2. If it's a **new named token type** (not an addition to an existing map), extend the augmentation in [frontend/src/material-ui-pigment-css.d.ts](frontend/src/material-ui-pigment-css.d.ts). The `CustomSpacing`, `CustomBorderRadius`, and `ZIndex` interfaces are already augmented there — mirror the pattern. +3. Do **not** add a raw SCSS variable in `_variables.scss` unless it maps to a theme value via CSS vars, matching the existing `$spacing` map. + +--- + +## Pigment-CSS `styled()` + +Use for anything that goes beyond utility classes: MUI wrappers, custom layout containers, anything that needs to read theme tokens. + +### Rules + +- Import: `import { styled } from "@mui/material-pigment-css";` (**not** `@mui/material/styles`). +- Named `Styled<Base>` (e.g. `StyledMuiButton`, `StyledMuiDialog`, `LayoutContainer`) and defined **above** the exported component in the same file. +- Access tokens via `theme.customProperties.*` / `theme.palette.*` / `theme.breakpoints.up(...)`. +- Style names use `camelCase` when using object syntax. When using string selectors (`> .content`, `& .MuiDialog-paper`), quote them. +- Media queries via `theme.breakpoints.up(<key>)` — never hardcode a `min-width` pixel value. +- Do **not** target MUI internal classes (`.MuiButtonBase-root`, `.MuiOutlinedInput-notchedOutline`, …) if you can style via a prop or a named slot instead — they change between MUI versions. + +### Canonical examples + +```tsx +// Canonical wrapper style — styled(MuiPrimitive) reading a theme token +const StyledMuiButton = styled(MuiButton)(({ theme }) => ({ + borderRadius: theme.customProperties.borderRadius.xs, +})); +``` + +```tsx +// styled('main') layout container — media queries via breakpoints +const LayoutContainer = styled("main")(({ theme }) => ({ + width: "100%", + display: "flex", + flexDirection: "column", + padding: theme.spacing(2), + + [theme.breakpoints.up("md")]: { padding: theme.spacing(4) }, + [theme.breakpoints.up("lg")]: { padding: theme.spacing(6) }, + [theme.breakpoints.up("xl")]: { padding: theme.spacing(8) }, + + "> .content": { + maxWidth: "82.5rem", + width: "100%", + }, +})); +``` + +### `sx` prop — for one-off theme access, not for hardcoded styles + +Unlike vanilla MUI, the `sx` prop in this project is provided by **Pigment-CSS** and compiled to static CSS at build time — it has no runtime cost. It is an accepted escape hatch when you need to pull a **theme value** (palette, `zIndex`, `spacing()`, `transitions`) onto a one-off element that doesn't warrant its own `styled()` wrapper. + +Rules: +- Always use the callback form: `sx={(theme) => ({ ... })}`. The static object form loses theme access. +- Only reach for `sx` when the style **needs `theme`**. Hardcoded values (`{ padding: 8, color: "#fff" }`) belong in `styled()` — or better, in a utility class. +- Prefer, in order: utility class → `styled()` component → `sx` prop. +- `sx` is fine on plain elements (`<div>`, `<span>`, `<pre>`, SVG `<path>`) and on MUI components. Do not chain multiple `sx` overrides across parent/child when a single `styled()` wrapper would express the same intent. + +Real examples from the repo: + +```tsx +// Full-screen overlay — pulls zIndex + palette from theme +<div + ref={nodeRef} + className={classes["loading"]} + sx={(theme) => ({ + backgroundColor: theme.palette.common.white, + zIndex: theme.zIndex.loading, + })} +/> +``` + +```tsx +// SVG icon path — fill via theme +<path + d="..." + sx={(theme) => ({ fill: theme.palette.common.white })} +/> +``` + +**Banned entirely**: `style={{ ... }}` inline styles. + +--- + +## CSS Modules (`*.module.css`) + +Use for **container-scoped** styles that need multiple related classes and don't map cleanly to utility classes. + +- File name: kebab-case matching the component, `<component>.module.css` (e.g. `<component-name>.module.css`). +- Class names inside the module: kebab-case (`.container`, `.content`, `.local`, `.dev`). +- Import as `classes`: `import classes from "./<component-name>.module.css";` +- Compose with `classnames` when applying conditionally: + +```tsx +import classNames from "classnames"; +import classes from "./<component-name>.module.css"; + +<div + className={classNames(classes["content"], { + [classes["local"]]: __ENV__ === "local", + [classes["dev"]]: __ENV__ === "dev", + })} +/> +``` + +- CSS Modules can (and do) coexist with utility classes on the same element. + +CSS Modules are for **containers** in the current repo. Presentational components under `@components/*` typically don't need them because they wrap a single MUI primitive via `styled()`. + +--- + +## Component `.scss` files (BEM, colocated) + +Reach for this only when Pigment-CSS + utility classes can't express the visual (rare — e.g. complex pseudo-element art, keyframes, deeply nested static markup). + +Rules: + +- File name: lowercase matching the component (e.g. `admin.scss` next to `Admin.tsx`). +- Import at the top of the `.tsx`: `import "./admin.scss";` +- Class names follow [BEM](https://getbem.com/naming/): `.block`, `.block__element`, `.block--modifier`. +- Use `@use "@styles/variables" as v;` and `@use "@styles/mixins/media-queries" as mq;` — never `@import`. +- Media queries live below the same-level ruleset, separated by a blank line: + +```scss +.admin { + display: flex; + flex-direction: column; + + @include mq.media-min(md) { + flex-direction: row; + } + + &__title { + margin-bottom: var(--mui-customProperties-spacing-md); + } + + &--compact { + padding: var(--mui-customProperties-spacing-xs); + } +} +``` + +- No hardcoded colors, spacing, radii — reference CSS variables from the theme. +- **Avoid `!important` at all costs.** If you must use it, add a one-line comment explaining why on the same line. + +--- + +## Media queries + +Two equivalent tools depending on where you are: + +- **In `.scss`**: `@use "@styles/mixins/media-queries" as mq;` then `@include mq.media-min(md) { ... }`, `mq.media-max(md)`, `mq.media-range(sm, lg)`. Breakpoint keys: `xs`, `sm`, `md`, `lg`, `xl`. +- **In Pigment-CSS `styled()`**: `[theme.breakpoints.up("md")]: { ... }` / `.down("md")` / `.between("sm", "lg")`. +- **In JS**: `useMediaQuery` — only when a CSS media query would be significantly more work. It's less efficient (JS-driven re-renders). + +Breakpoint values (informational — don't hardcode them): + +| Key | px | +|---|---| +| `xs` | 640 | +| `sm` | 768 | +| `md` | 1024 | +| `lg` | 1280 | +| `xl` | 1440 | + +--- + +## Typography + +- Always render text through MUI `<Typography variant="...">`. Never set `font-family`, `font-size`, or `font-weight` in a component. +- Available variants (from `themes/typography.ts`): `h1`–`h6`, `subtitle1`/`subtitle2`, `body1`/`body2`, `button`, `caption`, `overline`. Add new ones in `typography.ts` if a genuinely new style is needed. +- Font family is `InterTight` with weights 400/500/600/700 declared in `_fonts.scss`. Do not import additional fonts without team discussion. + +```tsx +// Good +<Typography variant="h4" className="mb-xl">{t("home__welcome")}</Typography> + +// Bad — hardcoded font sizing +<div style={{ fontSize: 24, fontWeight: 600 }}>{t("home__welcome")}</div> +``` + +--- + +## Global styles are read-only by default + +`src/styles/*` defines the design system: utility class generator, resets, fonts, globals. **Do not** add a new class to `_globals.scss` for a one-off need — that belongs in the component's own styling. Extend the global layer only when adding a token or a truly system-wide primitive, and prefer changing `themes/variables.ts` first. + +Files and their roles: + +| File | Purpose | +|---|---| +| `index.scss` | Entry — forwards the layers below (imported once in `App.tsx`). | +| `_globals.scss` | Layout / alignment utility classes (`flex`, `align-center`, `justify-*`, `position-*`). | +| `_export.scss` | Auto-generates spacing/gap/padding/margin/position utility classes from the spacing scale. | +| `_variables.scss` | SCSS `$spacing` / `$direction` maps that mirror MUI theme tokens as CSS variables. | +| `_fonts.scss` | `@font-face` declarations for InterTight. | +| `mixins/_generics.scss` | `rem($px)`, `get($map, $key)` helpers. | +| `mixins/_media-queries.scss` | `media-min` / `media-max` / `media-range` mixins. | +| `mixins/_normalize.scss` | CSS reset. | +| `vendors/toastify.css` | Third-party overrides. | + +--- + +## Things to avoid + +- `style={{ ... }}` inline styles. +- MUI `sx` with hardcoded values (colors, sizes, pixels). If it doesn't need `theme`, it doesn't need `sx` — use a utility class or `styled()`. +- Hardcoded colors (`#fafafb`, `#42a5f5`, …) — use `theme.palette.*` or add a token. +- Hardcoded spacing (`padding: 16px`, `margin: 8px`) — use utility classes or `theme.customProperties.spacing.*`. +- Hardcoded pixel border-radii — use `theme.customProperties.borderRadius.*`. +- Hardcoded pixel breakpoints — use `theme.breakpoints.*` or the `media-*` mixins. +- Hardcoded z-index numbers — extend `zIndex` in `themes/variables.ts` and reference by name. +- `!important` (unless commented and unavoidable). +- Targeting MUI internal `Mui*` classes when a slot / prop / named part exists. +- `@import` in SCSS — use `@use` / `@forward`. +- Adding a barrel `_index.scss` for components — colocate the `.scss` and import from the `.tsx`. +- Duplicating utility-class behaviour inside a component `.scss` (e.g. re-declaring `display: flex`). +- Adding new fonts, or overriding `font-family` at the component level. diff --git a/.github/instructions/typescript.instructions.md b/.github/instructions/typescript.instructions.md new file mode 100644 index 0000000..01c7022 --- /dev/null +++ b/.github/instructions/typescript.instructions.md @@ -0,0 +1,278 @@ +--- +description: TypeScript conventions for the frontend (naming, typing, imports, strictness). +applyTo: "frontend/src/**/*.{ts,tsx}" +--- + +# TypeScript conventions + +Scope: everything under `frontend/src`. This file is about TypeScript itself; every other instruction file layers domain rules on top of what's here. + +Complementary files (do not repeat their content here): +- [react.instructions.md](react.instructions.md) — component shape, hooks, JSX rules. +- [components-vs-containers.instructions.md](components-vs-containers.instructions.md) — folder decision + import allowlist. +- [services-api.instructions.md](services-api.instructions.md) — where domain-model interfaces (`I<Model>`) live. +- [state-stores.instructions.md](state-stores.instructions.md) — the `I<Name>Store` file-local interface convention. +- [forms.instructions.md](forms.instructions.md) — inline `I<FormName>` props next to the form component. + +The project uses TypeScript in **strict** mode with `noUnusedLocals`, `noUnusedParameters`, and `noFallthroughCasesInSwitch` enabled (see [frontend/tsconfig.app.json](frontend/tsconfig.app.json)). Do not weaken these settings and do not add `// @ts-ignore` or `// @ts-expect-error` unless a comment explains why and links a follow-up. + +--- + +## Naming conventions + +| Symbol | Convention | Example | +|---|---|---| +| Interface | `I` prefix, PascalCase | `IUser`, `IButton`, `IIcon` | +| Enum | `E` prefix, PascalCase, use `const enum` | `EPermission` | +| Type alias | PascalCase, no prefix | `Status`, `UserResponse` | +| Variables / functions | camelCase | `getUser`, `hasModification` | +| React components | PascalCase, `function` keyword | `function Button(...)` | +| Files | Match the default export name exactly | `Button.tsx`, `useUserStore.ts`, `IUser.ts` | + +Rules: +- **The file name must match the default export.** A component `AccessCard` lives in `AccessCard.tsx`; a hook `useCustomerInfo` lives in `useCustomerInfo.ts`. +- Use `.tsx` **only** when the file uses JSX. Pure TS (interfaces, stores, services, utils, enums) must be `.ts`. +- State setter names follow `set<Variable>`: `const [hasModification, setHasModification] = useState<boolean>(false);`. +- Callback prop handlers are named after the event they handle: `onClickSubmit`, `onChangeName`, not `handleSubmit`. + +--- + +## `interface` vs `type` + +Default to `interface`. Reach for `type` only when the shape can't be expressed as an interface. + +### Use `interface` for object shapes and extension + +```ts +interface IUser { + id: number; + name: string; + email: string; +} + +interface IAdmin extends IUser { + canDeleteUsers: boolean; +} +``` + +Extend third-party types the same way: + +```tsx +interface IButton extends ButtonProps { + target?: string; +} +``` + +### Use `type` for unions, intersections, primitives, generics, mapped types + +```ts +type ID = number | string; +type Status = "success" | "info" | "warning" | "error"; +type UserResponse = IUser | null; +type Dictionary<T> = { [key: string]: T }; +``` + +A string-union `type` is preferred over an `enum` when the values only need to travel through the type system (e.g. API response discriminants) — inference and autocompletion are simpler. + +### Never mix + +Do not declare the same concept as both an `interface` and a `type` (`IUser` interface + `UserType` alias). Pick one and export it. + +--- + +## Enums + +Use `const enum` with an `E` prefix and `default export`: + +```ts +const enum EPermission { + HomeRead = "HomeRead", + DashboardRead = "DashboardRead", + UikitRead = "UikitRead", +} + +export default EPermission; +``` + +- Values are string literals matching the key. Don't rely on numeric enum values. +- Prefer a string-union `type` when the set of values only crosses TypeScript boundaries (see previous section). Use an enum when the values need a stable runtime identity (permission names sent to the API, route keys, etc.). + +--- + +## Avoid `any` and `unknown` + +- **Never** use `any`. If you truly don't know the shape, type it precisely at the boundary (API response, third-party lib) and refine from there. +- `unknown` is acceptable only at trust boundaries (e.g. `JSON.parse`, `catch (err: unknown)`), and must be narrowed before use. +- Do not use `Function` or `object` as types. Prefer a specific signature or `Record<string, unknown>`. + +```ts +// Bad +function log(data: any) { console.log(data); } + +// Good +function log<T>(data: T): void { console.log(data); } + +// Acceptable at a boundary +try { + await save(); +} catch (err: unknown) { + if (err instanceof Error) toast.error(err.message); +} +``` + +--- + +## Functions + +- Non-component functions are arrow functions: `const getUser = (id: number): Promise<IUser> => { ... }`. +- React components use the `function` keyword (lexical `this` doesn't matter for components and stack traces read better). +- **Prefer a `const` variable over a function when there's no computation with parameters.** + +```ts +// Bad +const showButton = (): boolean => { + return providerId === client.providerId; +}; + +// Good +const showButton = providerId === client.providerId; +``` + +- Type the return type explicitly on exported functions and on any non-trivial function. Inference is fine for short local helpers. +- Prefer readonly parameters (`readonly IUser[]`) when the function must not mutate its input. + +--- + +## Generics + +Use generics when a function or type must preserve a caller's type. Constrain them. + +```ts +// Zustand store creation +export const useUserStore = create<IUserStore>((set) => ({ + user: undefined, + setUser: (user) => set({ user }), +})); + +// Constrained generic +function pickId<T extends { id: string | number }>(items: T[]): T["id"][] { + return items.map((i) => i.id); +} +``` + +Do not add generics that only appear once in the signature — that's just `any` in disguise. + +--- + +## Imports and path aliases + +The project defines path aliases in [frontend/tsconfig.app.json](frontend/tsconfig.app.json). **Always use them** when crossing an `app/` subfolder. Reserve relative imports for files within the same feature folder. + +| Alias | Points to | +|---|---| +| `@assets/*` | `src/assets/*` | +| `@components/*` | `src/app/components/*` | +| `@containers/*` | `src/app/containers/*` | +| `@enums/*` | `src/app/enums/*` | +| `@forms/*` | `src/app/forms/*` | +| `@hocs/*` | `src/app/hocs/*` | +| `@icons/*` | `src/app/icons/*` | +| `@pages/*` | `src/app/pages/*` | +| `@routes/*` | `src/app/routes/*` | +| `@services/*` | `src/app/services/*` | +| `@shared/*` | `src/app/shared/*` | +| `@stores/*` | `src/app/stores/*` | +| `@styles/*` | `src/styles/*` | + +```ts +// Bad +import IUser from "../../../services/users/interfaces/IUser"; +import EPermission from "../../enums/EPermission"; + +// Good +import IUser from "@services/users/interfaces/IUser"; +import EPermission from "@enums/EPermission"; + +// Relative import — OK, same feature folder +import "./button.scss"; +``` + +Order imports as: external packages → aliased internal → relative → styles. ESLint / Prettier handle formatting; do not fight the tooling. + +--- + +## Nullability and optional properties + +- Mark optional properties with `?` on the interface. Do not use `| undefined` for object properties. +- Prefer `undefined` over `null` for "no value" unless an external API forces `null`. Do not mix both for the same field. +- Provide defaults in destructuring rather than checking inside the function body: + +```tsx +// Icon component defaults — destructured, not assigned in the body +export default function AddRounded({ + className, + width = 24, + height = 24, + alt = "Add Rounded", +}: IIcon) { /* ... */ } +``` + +- Use optional chaining (`user?.email`) and nullish coalescing (`name ?? "Anonymous"`) instead of `&&` chains or `||` (which mishandles `""`, `0`, `false`). + +--- + +## File content order + +Keep files predictable. Within a `.ts` / `.tsx` file: + +1. Imports +2. Interfaces, types, enums +3. Constants / module-level variables +4. Helper functions +5. Styled components / private components +6. The public (usually only) exported component or function + +Within a React component body: hooks → variables → functions → effects → return. Sort alphabetically within a group when it doesn't hurt readability. + +--- + +## Vite / ambient types + +- Vite-injected constants (e.g. `__API_URL__` used in `@services/axiosInstance`) are declared in [frontend/src/vite-env.d.ts](frontend/src/vite-env.d.ts). Add new ones there, don't re-declare inline. +- Environment variables use `import.meta.env.VITE_*`. Type them via `ImportMetaEnv` augmentation in `vite-env.d.ts`. +- Global CSS / asset modules already have declarations; don't add `declare module "*.scss"` locally. + +--- + +## Interfaces live next to what they describe + +For domain models, colocate the interface under the owning service, following the existing layout: + +``` +src/app/services/users/ + interfaces/ + IUser.ts <-- default export, one interface per file + usersService.ts +``` + +Component-local prop interfaces stay in the component file: + +```tsx +interface IButton extends ButtonProps { + target?: string; +} + +export default function Button({ children, ...props }: IButton) { /* ... */ } +``` + +Rule of thumb: one exported interface per file when it's a shared domain type; inline when it's only used by that component. + +--- + +## Things to avoid + +- `any`, `Function`, `object`, `{}` as a type. +- Non-null assertions (`value!`) — narrow the type instead. +- `as` casts, except when narrowing after a runtime check or bridging a third-party type. Never `as unknown as T`. +- Type-only side effects (`import "some/type";` used just to trigger declaration merging) — use `import type` explicitly. +- Re-exporting via `export * from` in barrels — imports go through path aliases directly to the source file. diff --git a/frontend/docs/ComponentsAndContainers.md b/frontend/docs/ComponentsAndContainers.md deleted file mode 100644 index 26fb89e..0000000 --- a/frontend/docs/ComponentsAndContainers.md +++ /dev/null @@ -1,20 +0,0 @@ -# How to choose between Component or Container - -## Component - -- Manages how things look -- Has no dependencies on the rest of the app -- Doesn't specify how data is loaded or mutated -- Receives data and callbacks exclusively via props -- Rarely has own state (when it does, it’s UI state rather than data) -- Easy examples: Button, Table, Spinner, etc... - -## Container - -- Is often stateful, as it tends to serve as data sources. -- Is concerned with how things work -- May contain both presentational and container components inside but usually don’t have any DOM markup of their own except for some wrapping divs, and never have any styles. -- Provides the data and behavior to presentational or other container components. -- Examples: UserInfo, ShoppingCart, etc... - -> Don’t take the component VS container separation as a dogma. Sometimes it doesn’t matter or it’s hard to draw the line. If you feel unsure about whether a specific element should be a component or a container, it might be too early to decide. Don’t sweat it! diff --git a/frontend/docs/ReactStandards.md b/frontend/docs/ReactStandards.md deleted file mode 100644 index 67bb10d..0000000 --- a/frontend/docs/ReactStandards.md +++ /dev/null @@ -1,222 +0,0 @@ -# React Standards - -## Code formatting - -- Prettier is used to format all TypeScript code -- You should set your editor of choice to format on save using prettier. This will prevent code changes on formatting - -## Designs - -- UI designs should be followed as closely as possible. If it's not possible or costly to match the design, the designer and the developer need to plan a meeting to discuss options. -- Most design viewing tools have ways to inspect the design to see values like colors & spacing. Currently we use Figma which has an Inspector tab on the right which provides this functionality -- Sometimes a element may not be selectable with the inspector due to it being hidden under a different element, to access the element underneath use the panel on the left to find your element - -## Functions - -- Only use functions when needed. - - - For example: - -```ts -const showButton = (): boolean => { - return providerId === client.providerId; -}; -``` - -- Should be a constant variable - `const showButton = providerId === client.providerId;` -- Callbacks should be named after the event -- For example a `onClick` callback function name should always start with "onClick" - `onClick={onClickAmountOption}` - -- Functions that aren't React components should be camel case: `onChange` not `OnChange` -- Use arrow functions -- Use a normal function for react components. (The benefit of lexical scope of `this` is not present for components) - -## Interfaces and Types - -- We favor using `interface` most of the time, but `type` can be used in certain scenarios -- We avoid `any` or `unknown` as much as possible -- Use an `interface` to define an object - -```ts -interface User { - id: number; - name: string; - email: string; -} - -const user: User = { id: 1, name: "John Doe", email: "john.doe@gmail.com" }; -``` - -- Use an `interface` to extend another `interface` - -```ts -interface Admin extends User { - canDeleteUsers: boolean; -} - -const adminUser: Admin = { - id: 2, - name: "Jane Doe", - email: "jane.doe@gmail.com", - canDeleteUsers: true, -}; -``` - -Use `type` when defining a complex type - -```ts -type ID = number | string; -type UserResponse = User | null; -type Dictionary<T> = { [key: string]: T }; -``` - -Use `type` when you want a simpler `enum` using a string union. -Results with easier type inference(intellisense can be easier with this than `enum`) -and can simplify usage for API endpoints return objects - -```ts -type Status = "success" | "info" | "warning" | "error"; -``` - -## Labels - -- Labels should always be passed between components as the translated value -- You should never see code like below where a property is being translated: - `<Button>{t(props.label)}</Button>` -- The translate function should be called in the parent component and the child just renders the property as is: - `<Button>{props.label}</Button>` -- Use the built in [interpolation functionality](https://www.i18next.com/translation-function/interpolation) instead of `.replace()` for example: - -```ts -t("Plan_coMemberFee_label", { - CoMemberFee: formatCurrency(amount), - CoMemberFeeType: feeTypeLabel, -}); -``` - -## Plurals - -- Here is how you should manage translations with plurals. [Reference](https://www.i18next.com/translation-function/plurals) - -`component.tsx` - -```ts -t("asset__share_modal_title", { - count: assets.length, -}); -``` - -`en.json` - -```json -{ - "asset__share_modal_title": "Share asset", - "asset__share_modal_title_other": "Share assets" -} -``` - -- Do not do this in your `component.tsx` - -```ts -{ - assets.length === 1 - ? t("asset__share_modal_title") - : t("asset__share_modal_title_other"); -} -``` - -## State - -- When using useState, the set function should always be named `set[NameOfVariable]` - -```ts -const [hasModification, setHasModification] = useState<boolean>(false); -``` - -## Skeletons - -[Mui Skeleton](https://mui.com/components/skeleton) - -- Skeletons should match the content about to be rendered as closely as possible -- Where possible use the same container elements for the skeletons as the content being rendered - -Ex: -Good - -```tsx -<div className="my-container"> - {isFetchingData ? ( - <Skeleton className="my-container__item" animation="wave" /> - ) : ( - <CustomImage className="my-container__item" src="..." /> - )} -</div> -``` - -Wrong - -```tsx -{ - isFetchingData ? ( - <div className="my-container"> - <Skeleton className="my-container__item" animation="wave" /> - </div> - ) : ( - <div className="my-container"> - <CustomImage className="my-container__item" src="..." /> - </div> - ); -} -``` - -## Styles - -- All styling that affects basic MUI components should be in the theme -- Styling for specific component or specific container should be in proper styled component or proper `.scss` file -- Avoid inline styles on components at all costs -- All colors should be in theme.palette -- Use the MaterialUI Typography component for re-usable font styles which are defined in the theme -- Avoid `!important` at all costs, if it must be used, it must have a comment explaining why -- All media queries should be below the other styles of the same level, separated by a blank line -- Avoid using selectors for internal Material UI elements as they can change on upgrades, try to find other properties on the elements that reference those elements instead -- All style names must be in camel case format starting with a lowercase letter, ex: `listItem: { display: block; }` - -## Responsiveness - -- Always test using different screen sizes to check for layout issues -- The MaterialUI layout components have properties built for responsiveness, see their [official documentation](https://mui.com/material-ui/guides/responsive-ui/) -- `useMediaQuery` should only be used when css media queries would be significantly more work, since `useMediaQuery` is less efficient (it uses JavaScript and requires the component to re-render) - -## Files - -- The `tsx` extension should only be used when needed, if the file doesn't use the React syntax, it should be a ts file -- File name & case should match their default export For example a file that has a default export of: - - A component called `AccessCard` should be called `AccessCard.tsx` - - A hook called `useCustomerInfo` should be called `useCustomerInfo.ts` - -## Order - -- Try to keep the order of content between similar files consistent where possible -- Basic order of React component file contents: - - Imports - - Interfaces, types & enums - - Variables - - Functions - - Styles - - Private components - - Public/exported components (typically only one) -- Basic order of React component content: - - Hooks - - Variables - - Functions - - Effects - - Return -- Within the groups use alphabetical sorting when possible - -## Misc - -- Never use `dangerouslySetInnerHTML` -- Only use functionnal components. -- Business logic should be in the API instead of the client when possible diff --git a/frontend/docs/Styling.md b/frontend/docs/Styling.md deleted file mode 100644 index c7e6933..0000000 --- a/frontend/docs/Styling.md +++ /dev/null @@ -1,73 +0,0 @@ -# Styling - -There is multiple ways to go about styling in a react project. -This will explain to you the standard we propose to your project. - -> If any of this does not fit the team working on the project, please feel free to change it and to update this file to reflect the standard in the project - -## .scss files - -The main way you will be styling your components, will be by creating a new `.scss` file in the same directory as your component. -For example, if you have an `Admin.tsx` component, you would create an `admin.scss` file and import it in your `.tsx` -Here is a short code snippet showing how it would look like: - -> Please follow the [BEM standards](https://getbem.com/naming/) for you class naming convention - -```tsx -import "./admin.scss"; - -export const Admin = () => { - return <div className="admin">...</div>; -}; -``` - -```scss -.admin { - display: flex; - flex-direction: column; - gap: get-spacing(xs); - background-color: get-color(primary, main); -} -``` - -We also created a couple of utility `.scss` classes that you would be able to use. -They are mainly meant to be used as spacing classes. -See them in the `/frontend/src/styles` directory. - -Here is the example from above but using the utility classes - -```tsx -import "./admin.scss"; - -export const Admin = () => { - return <div className="admin flex-col gap-xs">...</div>; -}; -``` - -```scss -.admin { - background-color: get-color(primary, main); -} -``` - -> You are not forced to use any of the spacing classes, but with our experience, we find it usefull to be able to quickly align items using these - -## Styled components (MUI) - -When you want to change a component coming from MUI, we recommend that you create a styled component of it inside the components folder. -You should go look at the `Button.tsx` component to see an example. - -This allows us to have the same styling for every button in the app, without messing with MUI theme. -You can still go and change components everywhere by changing it in `createTheme`, but we found that in most cases, when the app grows, it can cause a lot of annoying bugs. - -## SX (MUI) - -You have probably done something similar in the past - -```tsx -export const Example = () => { - return <Box sx={{display: flex, flex-direction: "column", etc...}}>...</div>; -}; -``` - -We do not recommend you using that, and would point you toward using a simple div and style it with our utility classes. From fe6290b5e76023ea3eee62224294d5d3f68d2f9a Mon Sep 17 00:00:00 2001 From: Geoffrey DULAC <g.dulac@wepoint.com> Date: Wed, 1 Jul 2026 15:53:55 -0400 Subject: [PATCH 2/6] add Skills --- .github/skills/add-container/SKILL.md | 243 +++++++++++ .github/skills/add-form/SKILL.md | 215 ++++++++++ .../add-form/assets/FormName.tsx.template | 107 +++++ .../assets/formName.schema.ts.template | 27 ++ .github/skills/add-i18n-key/SKILL.md | 328 +++++++++++++++ .github/skills/add-icon/SKILL.md | 379 ++++++++++++++++++ .../skills/add-icon/assets/Icon.tsx.template | 37 ++ .../assets/IconMultiColor.tsx.template | 42 ++ .github/skills/add-mui-component/SKILL.md | 263 ++++++++++++ .../assets/Component.tsx.template | 25 ++ .../assets/UikitBlock.snippet.tsx | 25 ++ .github/skills/add-page/SKILL.md | 180 +++++++++ .../skills/add-page/assets/Page.tsx.template | 18 + .../add-page/assets/page.route.tsx.template | 27 ++ .../add-page/assets/withAuthPage.tsx.template | 7 + .github/skills/add-service/SKILL.md | 200 +++++++++ .../add-service/assets/IModel.ts.template | 16 + .../assets/domainService.ts.template | 55 +++ .github/skills/add-store/SKILL.md | 225 +++++++++++ .../add-store/assets/domainStore.ts.template | 29 ++ 20 files changed, 2448 insertions(+) create mode 100644 .github/skills/add-container/SKILL.md create mode 100644 .github/skills/add-form/SKILL.md create mode 100644 .github/skills/add-form/assets/FormName.tsx.template create mode 100644 .github/skills/add-form/assets/formName.schema.ts.template create mode 100644 .github/skills/add-i18n-key/SKILL.md create mode 100644 .github/skills/add-icon/SKILL.md create mode 100644 .github/skills/add-icon/assets/Icon.tsx.template create mode 100644 .github/skills/add-icon/assets/IconMultiColor.tsx.template create mode 100644 .github/skills/add-mui-component/SKILL.md create mode 100644 .github/skills/add-mui-component/assets/Component.tsx.template create mode 100644 .github/skills/add-mui-component/assets/UikitBlock.snippet.tsx create mode 100644 .github/skills/add-page/SKILL.md create mode 100644 .github/skills/add-page/assets/Page.tsx.template create mode 100644 .github/skills/add-page/assets/page.route.tsx.template create mode 100644 .github/skills/add-page/assets/withAuthPage.tsx.template create mode 100644 .github/skills/add-service/SKILL.md create mode 100644 .github/skills/add-service/assets/IModel.ts.template create mode 100644 .github/skills/add-service/assets/domainService.ts.template create mode 100644 .github/skills/add-store/SKILL.md create mode 100644 .github/skills/add-store/assets/domainStore.ts.template diff --git a/.github/skills/add-container/SKILL.md b/.github/skills/add-container/SKILL.md new file mode 100644 index 0000000..6374046 --- /dev/null +++ b/.github/skills/add-container/SKILL.md @@ -0,0 +1,243 @@ +--- +name: add-container +description: "Add a new container to the web-react-skeleton React SPA. Use when: creating a new container, adding stateful UI that talks to a Zustand store / service / router / localStorage, wrapping presentational components with orchestration, scaffolding a folder under frontend/src/app/containers/, adding a provider, adding a banner/modal/consent widget, promoting a component to a container, or extracting business logic out of a presentational component. Enforces the container folder layout (containers/<name>/<Name>.tsx + optional <name>.config.ts / <name>Helper.ts / interfaces/ / private sub-component subfolders), the components-vs-containers import allowlist (containers may import stores/services/router; components may not), the no-heavy-markup rule, the private-sub-component rule (promote to @components on second consumer), and the container-is-not-a-page rule (no routes.ts registration, no withAuth, no lazy)." +argument-hint: "<ContainerName> — e.g. 'CartDrawer' or 'CartDrawer, orchestrates the cart store + navigate to checkout'" +--- + +# Add a Container + +Add a new container under `frontend/src/app/containers/<containerName>/` following the "stateful orchestration, no visuals of its own" convention. + +## When to Use + +- User asks to "add a container", "create a container", "add a provider", "add a banner/modal/consent", or scaffolds a folder under `containers/`. +- User has a piece of UI that needs a **store** (`useUserStore`), a **service** (`postLogin`), the **router** (`useNavigate`, `useParams`, `useLocation`), `localStorage` / `sessionStorage`, or an env var — but is not itself a route target. +- User is about to import `@stores/*` / `@services/*` / `react-router-dom` inside a file under `@components/*` (that file is a container in the wrong folder — move it). +- User is promoting a `@components/*` file that has grown a store dep or an `useEffect` doing I/O. + +Do **not** use this skill for: +- A **route target** (has a URL, is registered in `routes.ts`) — use `add-page`. +- A **form** (Yup schema + `<form onSubmit>`) — use `add-form`. +- A **presentational primitive** (no store/service/router imports, no I/O) — that's a component; use `add-mui-component` or edit `@components/*` directly. +- Extracting one function out of an existing container — just add the helper next to it (no skill needed). + +## The Container Pattern (Ground Truth) + +**One folder, one container.** The folder can grow its own private subtree. Minimum is a single file: + +``` +containers/ + <containerName>/ # lowerCamelCase folder + <ContainerName>.tsx # PascalCase file, default export = the container +``` + +Full pattern (only add what you need): + +``` +containers/ + <containerName>/ + <ContainerName>.tsx # the container (default export) + <containerName>.config.ts # static config (constants, tables) + <containerName>Helper.ts # pure helpers (localStorage I/O, parsing) + interfaces/ + I<Model>.ts # one interface per file, I-prefixed + <subComponentName>/ # PRIVATE sub-component subfolder (lowerCamelCase) + <SubComponentName>.tsx # PascalCase file + <sub-component-name>.module.css # optional colocated styles +``` + +Real examples in the repo: +- `containers/authProvider/AuthProvider.tsx` — single file, wraps children with an auth check. +- `containers/debugBanner/DebugBanner.tsx` + `debug-banner.module.css` — single container with one style file. +- `containers/cookieConsent/` — full pattern: `CookieConsent.tsx` + `cookieConsent.config.ts` + `cookieConsentHelper.ts` + `interfaces/*` + private `cookieBanner/` and `cookieModal/` sub-component folders. + +## Inputs to Confirm + +Before generating any files, confirm with the user (ask only what's missing): + +1. **Container name** in `PascalCase` (e.g. `CartDrawer`, `SessionTimer`, `FeatureFlagProvider`). Folder name is the same in `lowerCamelCase` (`cartDrawer/`). +2. **What state / side effects does it own?** Store reads/writes, service calls, `localStorage` keys, router interactions, env checks. If the answer is "none", it's a component, not a container — stop. +3. **Does it wrap children (`children: ReactNode`)** or render fully on its own? Providers wrap; banners/modals don't. +4. **Does it need private sub-components?** If it produces visible UI more complex than `<>{children}</>` or a single component call, the visuals should be a presentational sub-component (private under this container) or an existing `@components/*`. +5. **Consumers** — where will it be mounted? `App.tsx`, a specific page, `Router.tsx`, an HOC. The container is not registered anywhere central; the consumer imports it directly. + +If the container needs a **new i18n key** or a **new store**, invoke `add-i18n-key` / `add-store` first (or in the same session) — do not scaffold against placeholders. + +## Procedure + +Follow every step in order. + +### 1. Confirm it's actually a container (not a component / page / form) + +Run the decision checklist from [components-vs-containers.instructions.md](../../instructions/components-vs-containers.instructions.md#decision-checklist). First "yes" wins: + +1. Does it read/write a Zustand store? → **container** +2. Does it call a service (axios) or `localStorage` / `sessionStorage`? → **container** +3. Does it use `useNavigate`, `useParams`, `useLocation`? → **container** (or a page) +4. Does it fetch data on mount, or run business logic in `useEffect`? → **container** +5. Does it need to know about the current user, permissions, cookie consent, or environment? → **container** +6. Would a design-system Storybook page render it without any providers besides theme/i18n? → **component** (stop this skill) +7. Can it be reused on multiple pages, receiving different data via props? → **component** (stop this skill) + +If it also has a URL → **page**, not a container. Stop this skill and invoke `add-page`. + +If genuinely unsure, start as a `@components/*` file. Promoting a component to a container later is cheap; extracting a component from a container is not. + +### 2. Verify prerequisites exist + +Before creating files, confirm each dependency exists: + +- **Stores** the container consumes (`@stores/*`). Missing → invoke `add-store` first. +- **Services** the container calls (`@services/*`). Missing → invoke `add-service` first. +- **i18n keys** used by the container. Missing → invoke `add-i18n-key` first. +- **Presentational sub-components** it composes. If they exist in `@components/*`, reuse them. If they'll be private to this container, plan the sub-component subfolder(s) in step 4. + +### 3. Create the container file + +Create `frontend/src/app/containers/<containerName>/<ContainerName>.tsx`. + +Rules (from [components-vs-containers.instructions.md](../../instructions/components-vs-containers.instructions.md#what-belongs-in-containers) + [react.instructions.md](../../instructions/react.instructions.md) + [typescript.instructions.md](../../instructions/typescript.instructions.md)): + +- **Default export** = a named function matching the file name in `PascalCase`. +- **Props interface** inline as `I<ContainerName>`, `I`-prefixed. If the container has no props, omit the interface. +- **Absolute imports** via aliases (`@stores/*`, `@services/*`, `@components/*`, `@icons/*`, `@shared/*`, `@enums/*`, `@pages/*`, `@routes/*`). Never relative across folders. For **private sub-components under this container**, relative imports (`./cookieBanner/CookieBanner`) are used in the repo — either works, be consistent within the file. +- **Orchestration only.** The body reads stores, calls services, navigates, sets `localStorage`, and renders **either** a single component / composed sub-components / `children` — with **no `styled()` blocks, no big JSX trees, no new `.scss` file at container level** unless a single thin wrapper `div` is unavoidable (see `DebugBanner` for the exception, not the rule). +- `useEffect` may perform I/O (service call, localStorage read, subscription). Include the full dependency array; ESLint enforces this. +- `useCallback` for handlers passed to sub-components. +- `useTranslation` from `react-i18next` when needed; translate at the call site, pass strings down. Reserved key rule: URL-related lookups use `t("locale__key")` (see [i18n.instructions.md](../../instructions/i18n.instructions.md)). +- **Toasts** use stable `toastId`s (see [i18n.instructions.md](../../instructions/i18n.instructions.md#error-and-toast-copy) + [services-api.instructions.md](../../instructions/services-api.instructions.md)). +- **Service call shape** in an effect: `.then(({ data }) => …).catch((error) => { … }).finally(...)`. Handle known errors first (e.g. `error.response?.status === 401`), fall back to `toast.error(t("errors__generic"), { toastId: "generic" })`. Do **not** wrap the service in `try/catch` — services already normalize errors. +- **Router navigation** uses `useNavigate` + `<route>.paths[t("locale__key")]`: + ```tsx + navigate(loginRoute.paths[t("locale__key")], { replace: true }); + ``` + +Reference skeletons: +- **Wraps children (provider):** see `containers/authProvider/AuthProvider.tsx`. +- **Standalone widget:** see `containers/cookieConsent/CookieConsent.tsx`. +- **Env-gated banner with localStorage:** see `containers/debugBanner/DebugBanner.tsx`. + +### 4. Add sub-artefacts only if needed + +Add each of these **only** when the container has enough surface to justify it. Do not scaffold empty files. + +**`<containerName>.config.ts`** — static config (arrays, constants, lookup tables) consumed by the container and its sub-components. Default export or named exports; either matches the repo (`cookieConsent.config.ts` uses a default export). + +**`<containerName>Helper.ts`** — pure functions that live outside React (localStorage getters/setters, parsers, predicate helpers). Named exports only. Example: `cookieConsentHelper.ts` exports `COOKIE_PREFERENCES`, `getCookieConsentPreferences`, `setCookiePreferencesInStorage`, `hasConsent`. Helpers may be imported by other files outside the container (`hasConsent` is imported by `App.tsx`). + +**`interfaces/I<Model>.ts`** — domain types used by this container. **One interface per file**, `I`-prefixed. Consumed via `@containers/<containerName>/interfaces/I<Model>` alias imports. + +**`<subComponentName>/<SubComponentName>.tsx` (+ optional `.module.css`)** — a **private** presentational sub-component owned by this container. Rules: +- Sub-component is a **regular presentational component** (see [components-vs-containers.instructions.md](../../instructions/components-vs-containers.instructions.md#what-belongs-in-components)) — no store/service/router imports, no I/O. If it needs any of those, either promote its logic up into the container or split off another container. +- Sub-component subfolder is `lowerCamelCase`; file is `PascalCase.tsx`. +- Colocated `.module.css` uses `kebab-case` naming (`cookie-banner.module.css`) or SCSS if the component's styling calls for BEM (see [styling.instructions.md](../../instructions/styling.instructions.md)). +- **Only imported by the parent container.** If a second consumer wants it, follow step 6 (promote to `@components/*`). + +### 5. Wire the container into its consumer + +The container is not registered anywhere central. The consumer imports it directly: + +- **App-wide widget** (cookie banner, feature flag provider) → `App.tsx`. +- **Auth wrapper** → the `withAuth` HOC (`hocs/withAuth.tsx`). +- **Dev-only banner** → `routes/Router.tsx` behind an `__ENV__` check. +- **Page-scoped orchestration** → the specific page under `@pages/*`. + +Example: + +```tsx +// App.tsx +import CookieConsent from "@containers/cookieConsent/CookieConsent"; +// … +return ( + <> + <Router /> + <CookieConsent /> + </> +); +``` + +If the container wraps children, its consumer wraps its own subtree: + +```tsx +<AuthProvider permission={permission}> + <PageComponent /> +</AuthProvider> +``` + +Do **not**: +- Register the container in `@routes/routes.ts` (that's for pages). +- Wrap the container in `withAuth` (that's for pages). +- Lazy-load the container with `React.lazy` (that's for pages). +- Add a `*.route.tsx` file next to it. + +### 6. Watch for the "promote to component" trigger + +The moment a private sub-component gets a **second consumer** (outside its owning container), move it: + +1. Create `@components/<subName>/<SubName>.tsx` (+ colocated styles if any). +2. Update the original container's import to the new `@components/*` path. +3. **Delete** the private copy under `containers/<containerName>/<subName>/`. +4. If it's a wrap of an MUI primitive, invoke `add-mui-component` to also register it in the UiKit page. + +Never keep two copies of the same component. + +### 7. Verify + +From `frontend/`: + +1. **`yarn lint:scripts`** — catches disallowed imports (`@components/*` importing from `@stores/*` is flagged by structure; `@containers/*` importing from `@components/*` is allowed). +2. **`yarn build`** — full type check via `tsc -b`. +3. **`yarn dev`** and exercise the container: + - The consumer mounts it (App / page / HOC / Router). + - State reads (stores, `localStorage`, URL params) resolve without a null-flash. + - Service calls succeed → the expected UI renders; fail → the expected `toast.error` fires exactly once per burst (stable `toastId`). + - Navigation calls land on the right localized URL (`/en/...` or `/fr/...`). +4. **Grep sanity check** for the container name across the repo: exactly one definition, at least one consumer. + +## Completion Checklist + +Before reporting done, verify all apply: + +- [ ] Folder `frontend/src/app/containers/<containerName>/` exists, `lowerCamelCase`. +- [ ] `<ContainerName>.tsx` exists, `PascalCase` file, default export = a named function. +- [ ] Props interface (if any) is inline, `I`-prefixed, and named `I<ContainerName>`. +- [ ] Container imports at least one of: `@stores/*`, `@services/*`, `react-router-dom` router hooks, `localStorage` / `sessionStorage`, or an env var. If it imports **none** of those, it's a component — move it. +- [ ] Container does **not** introduce a `styled()` block, a large JSX tree, or a new `.scss` file for reusable visuals. Reusable visuals live in `@components/*` or in a private sub-component folder. +- [ ] Optional `<containerName>.config.ts` / `<containerName>Helper.ts` / `interfaces/I<Model>.ts` files exist only if they carry real content. +- [ ] Private sub-components live under `<subComponentName>/<SubComponentName>.tsx` and are only imported by the parent container. +- [ ] Service calls in effects use `.then/.catch/.finally`, not `try/catch`, and specific errors are handled before falling back to `errors__generic`. +- [ ] `toast.error(...)` calls include a stable `toastId`. +- [ ] Navigation uses `<route>.paths[t("locale__key")]`, never a hard-coded URL string. +- [ ] The container is **not** in `@routes/routes.ts`, not wrapped in `withAuth`, not lazy-loaded, and has no `*.route.tsx` sibling. +- [ ] At least one consumer imports it (`App.tsx`, a page, `Router.tsx`, `withAuth`, or another container). +- [ ] `yarn lint:scripts` passes. +- [ ] `yarn build` passes. + +## Anti-patterns to Reject + +- Placing a file that imports `@stores/*` / `@services/*` / `react-router-dom` router hooks under `@components/*` — that's a container in the wrong folder. +- Placing a file with no store / service / router / I/O dep under `@containers/*` — that's a component; move it to `@components/*`. +- Registering the container in `@routes/routes.ts`, adding a `*.route.tsx`, or wrapping it in `withAuth` — those belong to pages. +- Lazy-loading the container with `React.lazy` — only pages are lazy-loaded. +- Growing a container into a `div`-soup of layout + `styled()` blocks — extract the visuals as a private sub-component or a `@components/*` file, leave the container as orchestration. +- Making a private sub-component reach into a store or service — private sub-components are presentational only. Push the logic up to the parent container. +- Keeping two copies of a sub-component after a second consumer appears — promote to `@components/*` and delete the private copy. +- Wrapping the service call in `try/catch` — services already normalize errors; use `.then/.catch/.finally`. +- `toast.error(...)` without a `toastId` — duplicate toasts stack. +- Hard-coded URL strings in `navigate(...)` — always `<route>.paths[t("locale__key")]`. +- Reading the locale from `navigator.language` or `location.pathname.split("/")[1]` — always `t("locale__key")`. +- Nesting more than one component under a single container folder (e.g. two `.tsx` files at the folder root) — one folder, one container. Sub-components go in their own subfolder. +- Relative imports across folders (e.g. `../../stores/userStore`) — always alias. +- Defining container-scoped interfaces under `@shared/interfaces/*` — keep them under the container's own `interfaces/` folder. + +## References + +- [components-vs-containers.instructions.md](../../instructions/components-vs-containers.instructions.md) — the full rules this skill enforces, incl. the decision checklist and import allowlist table. +- [react.instructions.md](../../instructions/react.instructions.md) — component shape, hooks, `useCallback`, `Dispatch<SetStateAction>` typing. +- [typescript.instructions.md](../../instructions/typescript.instructions.md) — `I` prefix, one interface per file, alias imports. +- [state-stores.instructions.md](../../instructions/state-stores.instructions.md) — which layers may import `@stores/*`; hydration / reset patterns for `AuthProvider`. +- [services-api.instructions.md](../../instructions/services-api.instructions.md) — service call shape used in `useEffect`. +- [routing-and-auth.instructions.md](../../instructions/routing-and-auth.instructions.md) — navigation, `AuthProvider`, `withAuth` HOC. +- [styling.instructions.md](../../instructions/styling.instructions.md) — when a colocated `.module.css` / `.scss` is acceptable on a container or its sub-components. +- [i18n.instructions.md](../../instructions/i18n.instructions.md) — reserved keys, toast `toastId` convention. +- `add-store`, `add-service`, `add-i18n-key`, `add-mui-component`, `add-page` skills — invoked when a prerequisite or consumer is missing. diff --git a/.github/skills/add-form/SKILL.md b/.github/skills/add-form/SKILL.md new file mode 100644 index 0000000..d6883b0 --- /dev/null +++ b/.github/skills/add-form/SKILL.md @@ -0,0 +1,215 @@ +--- +name: add-form +description: "Add a new form to the web-react-skeleton React SPA. Use when: creating a new form, adding a Yup schema, adding form validation, wiring a login/register/edit/create form, adding fields with FieldHelperText, or scaffolding a folder under frontend/src/app/forms/. Enforces the two-file form pattern (FormName.tsx + formName.schema.ts), controlled useState with a single object, submit+blur validation gated by a validated flag, FieldHelperText wiring, and the parent-owns-Loading rule." +argument-hint: "<FormName> <domain> — e.g. 'RegisterForm auth'" +--- + +# Add a Form + +Add a new form under `frontend/src/app/forms/<domain>/<formName>/` following the project's Yup + controlled-state + `FieldHelperText` conventions. + +## When to Use + +- User asks to "add a form", "create a form", "add validation", or "add a Yup schema". +- User wants a submit-to-service action with per-field errors (login, register, edit, create, filter, etc.). +- User references adding to `forms/`. + +Do **not** use this skill for: +- A page-level UI that has no submit and no schema — that's a container or page. +- Adding a single input to an existing form (just edit the schema + JSX). +- Building a service or a page — see `add-page` / `add-service` skills. + +## Inputs to Confirm + +Before starting, confirm with the user (ask only what's missing): + +1. **Form name** in `PascalCase` ending in `Form` (e.g. `RegisterForm`, `EditProfileForm`, `SearchForm`). Folder name derives as lowerCamelCase. +2. **Domain** — the parent folder under `forms/` (e.g. `auth`, `user`, `project`). Create the folder if it doesn't exist. +3. **Service function this form submits to** — full path (e.g. `postRegister` from `@services/auth/authService`). Must exist already; if not, the user needs `add-service` first. +4. **Request interface** — the `I<Something>` type describing the form values. It must live under the owning service (e.g. `@services/auth/interfaces/IRegister`). Reuse the request interface as the state type; do **not** duplicate it in the form. +5. **Fields** — for each field: name, type (string/number/…), and validation rules (required, min, max, email, matches, oneOf, ref-based, etc.). +6. **On-success behavior** — navigate where, hydrate which store, set which localStorage keys. + +If the user's request is loose ("add a register form"), infer the shape from the closest existing form (LoginForm) and confirm assumptions in the summary. + +## The Form Pattern (Ground Truth) + +Every form is exactly two files: + +``` +frontend/src/app/forms/<domain>/<formName>/ +├── <FormName>.tsx # default export, PascalCase, inline I<FormName> props interface +└── <formName>.schema.ts # default export named <formName>Schema, Yup schema +``` + +No `interfaces/` folder, no `types.ts`, no colocated helpers. The request interface lives under `@services/<domain>/interfaces/`. + +## Procedure + +Follow every step in order. Do not skip. + +### 1. Verify prerequisites exist + +Before generating any files, confirm: + +- **Service function exists** at the path the form will call. If not, stop and invoke the `add-service` skill to create it — do not scaffold the form against a placeholder. +- **Request interface exists** at `@services/<domain>/interfaces/I<Something>.ts`. This will be the `useState<...>` type — do not redefine it in the form. Missing → `add-service` covers it. +- **Response interface exists** (usually `IUser` or similar) — needed for typed `.then(({ data }) => …)`. Missing → `add-service` covers it. + +If any of the above is missing, run `add-service` first, then resume this skill. + +### 2. Add i18n keys (BLOCKER — do this first) + +Every form needs three groups of keys. Locale JSONs are generated by `yarn sheet2i18n` — **never hand-edit** `assets/locales/*.json`. + +Required additions to the Google Sheet: + +- **Field labels** — one per field: `<namespace>__<fieldname>` (e.g. `register__email`, `register__password`). The **namespace usually matches the page** that hosts the form, not the form itself. +- **Submit button label** — e.g. `register__sign_up`. +- **Validation messages** — reuse when possible: + - `validations__required` + - `validations__min_characters` (interpolates `{{ min }}`) + - `validations__max_characters` (interpolates `{{ max }}`) + - Add a new `validations__<rule>` key only for genuinely new rules (e.g. `validations__passwords_must_match`, `validations__invalid_email`). +- **Error toast keys** — reuse `errors__generic` as the fallback; add a specific `errors__<case>` key for known API errors the form handles. + +Tell the user which exact keys to add, then run `yarn sheet2i18n`. If they can't update the sheet right now, ask whether to (a) pause, or (b) scaffold anyway and accept a build error until the keys land. Never hand-edit the JSONs. + +### 3. Create the schema + +Create `<formName>.schema.ts` from [assets/formName.schema.ts.template](./assets/formName.schema.ts.template). Replace `__formName__` and each field entry. + +Rules (from [forms.instructions.md](../../instructions/forms.instructions.md)): + +- **Named imports** from `yup`: `import { object, string, number, boolean, array, mixed, date, ref } from "yup"`. Never `import * as yup`. +- **Default export** named `<formName>Schema`. +- **`.label(...)` receives the i18n key**, not human text. `FieldHelperText` translates it into `{{ field }}`. +- **Validation messages are `validations__*` i18n keys**. Never inline strings. +- **Field names in the schema MUST match the state shape exactly** — the request interface field names. A mismatch silently hides the error in `FieldHelperText`. +- **Every validator that can fail carries a message key.** No `.required()` without an argument, no `.matches(regex)` without a message. +- **Cross-field comparisons use `ref`**: `.oneOf([ref("password")], "validations__passwords_must_match")`. +- No `.transform(...)` for i18n; keep the schema pure validation. + +### 4. Create the form component + +Create `<FormName>.tsx` from [assets/FormName.tsx.template](./assets/FormName.tsx.template). Replace placeholders and add one `<div className="mb-md">…</div>` block per field. + +**The three pieces of state, in order:** +```tsx +const [<formName>, set<FormName>] = useState<I<Request>>({ /* one entry per field */ }); +const [<formName>Validated, set<FormName>Validated] = useState<boolean>(false); +const [formErrors, setFormErrors] = useState<ValidationError[]>([]); +``` + +- **Single state object** typed against the request interface. Never one `useState` per field. +- **`<name>Validated` flag** gates blur validation so the user isn't yelled at while typing the first time. +- **`formErrors: ValidationError[]`** from Yup's `error.inner`. Do not flatten to strings. + +**`onSubmit` template (invariants):** +- `event.preventDefault()` first. +- `set<FormName>Validated(true)` before `validateSync`. +- `validateSync(values, { abortEarly: false })` inside a `try` — always `abortEarly: false`. +- Clear `formErrors` on validation success, before the async call. +- Call the service inside the `try` block so failed validation short-circuits. +- Service uses `.then(({ data }) => …).catch(…).finally(() => setIsLoading(false))`. +- `.catch` handles known errors first (e.g. `error.response?.data?.message === "…"`), then falls back to `toast.error(t("errors__generic"), { toastId: "generic" })`. +- Outer `catch (error)` handles Yup errors only via `error instanceof ValidationError` → `setFormErrors(error.inner)`. **Never toast validation errors.** + +**`onValidate` for `onBlur` (invariants):** +- Guarded by `if (<formName>Validated) { … }` — no-op until first submit attempt. +- Same `validateSync` + `catch` pattern. +- Wire to every field's `onBlur`, not `onChange`. + +**JSX layout (invariants):** +- Native `<form onSubmit={onSubmit}>` — never `onClick` on the submit button. +- Each field wrapped in `<div className="mb-md">…</div>` containing the input **and** its `<FieldHelperText fieldNames="…" formErrors={formErrors} />`. +- `TextField.onChange` receives `value: string` directly — the wrapper unwraps the event. +- State updates use the functional form: `set<FormName>((prevState) => ({ ...prevState, field: value }))`. +- `autoComplete` attribute on every relevant input: `username`, `current-password`, `new-password`, `email`, `given-name`, `family-name`, `postal-code`, or `off`. +- Labels translated at the call site: `label={t("…__field")}`. The child receives a string. +- Spacing via utility classes (`mb-md`, `flex-column`). No inline styles. +- Submit is `<Button type="submit">`. + +### 5. Do NOT render `<Loading />` inside the form + +The `<Loading />` overlay belongs on the **page**, not the form. The form only flips `setIsLoading`. If you're tempted to render `Loading` inside the form, stop — the page owns the overlay so it can sit above the layout. + +### 6. Wire the form into a page + +Edit the consuming page (or create it via `add-page` first): + +```tsx +const [isLoading, setIsLoading] = useState<boolean>(false); + +return ( + <> + <Loading isLoading={isLoading} /> + <Layout.Container> + {/* … */} + <<FormName> setIsLoading={setIsLoading} /> + </Layout.Container> + </> +); +``` + +The page owns `isLoading`; the form receives only the setter. + +### 7. Verify + +From `frontend/`: + +1. `yarn lint:scripts` — catches import errors, missing types, unused vars. +2. `yarn build` — full type check via `tsc -b`. If it fails on missing i18n keys, remind the user to run `yarn sheet2i18n`. +3. `yarn dev` and exercise the form: + - Submit empty → each field shows its `validations__required` error inline via `FieldHelperText`. + - Fix one field → the fixed field's error clears on blur (thanks to `onValidate` + the `Validated` flag), others remain. + - Successful submit → loading overlay appears, service is called, success path runs. + - API failure → toast shows either the specific message or `errors__generic`. + +## Completion Checklist + +Before reporting done, verify all apply: + +- [ ] Folder `frontend/src/app/forms/<domain>/<formName>/` exists. +- [ ] `<formName>.schema.ts` exists, named imports from `yup`, default export `<formName>Schema`, every `.label()` uses an i18n key, every validator has a `validations__*` message. +- [ ] `<FormName>.tsx` exists, inline `I<FormName>` props interface with `setIsLoading: Dispatch<SetStateAction<boolean>>`. +- [ ] Single-object `useState` typed against the service request interface. +- [ ] `<name>Validated` flag exists and gates `onValidate`. +- [ ] `formErrors: ValidationError[]` used unmodified by `FieldHelperText`. +- [ ] `validateSync` called with `{ abortEarly: false }`. +- [ ] Native `<form onSubmit>` with `<Button type="submit">`. +- [ ] Every field has `autoComplete`, `onBlur={onValidate}`, and a matching `<FieldHelperText fieldNames="…" formErrors={formErrors} />`. +- [ ] Field names in schema, state, and `FieldHelperText.fieldNames` all match exactly. +- [ ] Page owns `isLoading` and renders `<Loading />` outside the form. +- [ ] i18n keys communicated to the user (or already added + regenerated). +- [ ] `yarn lint:scripts` passes. +- [ ] `yarn build` passes (or fails only on pending i18n keys). + +## Anti-patterns to Reject + +- Inline validation strings — must be `validations__*` keys. +- One `useState` per field instead of a single object. +- `.label(t("..."))` — pass the raw key; `FieldHelperText` translates. +- Rendering Yup errors with anything other than `FieldHelperText` (no `.map(formErrors)`, no `<Typography color="error">`, no `alert`). +- `toast.error(...)` on validation errors — validation errors are inline only. +- `<Button onClick={onSubmit}>` instead of `<form onSubmit>` — breaks Enter-key submit. +- `<Loading />` inside the form. +- Omitting `{ abortEarly: false }` — only the first error surfaces otherwise. +- Skipping `event.preventDefault()`. +- Skipping the `<name>Validated` flag — fields turn red the moment the user tabs out of an empty field. +- Colocating multiple forms in one folder. +- Defining the schema inline in the `.tsx`. +- Adding an `onSubmit` prop that abstracts the service call — forms are bound to one service. +- Hand-editing `assets/locales/*.json` — always regenerate via `yarn sheet2i18n`. +- Duplicating the request interface inside the form — reuse the one under `@services/<domain>/interfaces/`. + +## References + +- [forms.instructions.md](../../instructions/forms.instructions.md) — the full rules this skill enforces. +- `add-service` skill — use to create the service + interfaces the form will consume. +- [services-api.instructions.md](../../instructions/services-api.instructions.md) — service call pattern used in `onSubmit`. +- [i18n.instructions.md](../../instructions/i18n.instructions.md) — key namespaces + `sheet2i18n` workflow. +- [react.instructions.md](../../instructions/react.instructions.md) — component shape, hooks, `Dispatch<SetStateAction>` typing. +- [components-vs-containers.instructions.md](../../instructions/components-vs-containers.instructions.md) — how forms differ from containers. +- [typescript.instructions.md](../../instructions/typescript.instructions.md) — `I` prefix, file naming. +- [mui-and-uikit.instructions.md](../../instructions/mui-and-uikit.instructions.md) — why `TextField.onChange` receives a string, not an event. diff --git a/.github/skills/add-form/assets/FormName.tsx.template b/.github/skills/add-form/assets/FormName.tsx.template new file mode 100644 index 0000000..fa2c1d4 --- /dev/null +++ b/.github/skills/add-form/assets/FormName.tsx.template @@ -0,0 +1,107 @@ +import Button from "@components/button/Button"; +import FieldHelperText from "@components/fieldHelperText/FieldHelperText"; +import TextField from "@components/textField/TextField"; +import __formName__Schema from "@forms/__domain__/__formName__/__formName__.schema"; +// Replace the following with the correct service + interface for this form: +// import { post__ServiceCall__ } from "@services/__domain__/__domain__Service"; +// import I__Request__ from "@services/__domain__/interfaces/I__Request__"; +import { + Dispatch, + FormEvent, + SetStateAction, + useCallback, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; +import { ValidationError } from "yup"; + +interface I__FormName__ { + setIsLoading: Dispatch<SetStateAction<boolean>>; + // Add cross-cutting parent callbacks here if needed, e.g.: + // onSuccess?: () => void; +} + +export default function __FormName__({ setIsLoading }: I__FormName__) { + const { t } = useTranslation(); + + // State — three pieces, in this order. + // Use the service request interface as the state type; do not redeclare fields. + const [__formName__, set__FormName__] = useState<I__Request__>({ + // one entry per field, matching the schema keys + }); + const [__formName__Validated, set__FormName__Validated] = + useState<boolean>(false); + const [formErrors, setFormErrors] = useState<ValidationError[]>([]); + + const onSubmit = useCallback( + (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + try { + set__FormName__Validated(true); + __formName__Schema.validateSync(__formName__, { abortEarly: false }); + setFormErrors([]); + setIsLoading(true); + post__ServiceCall__(__formName__) + .then(({ data }) => { + // Success side effects: localStorage, store hydration, navigation, toast. + // Keep this block short; move complex flows into a shared helper. + }) + .catch((error) => { + // Handle known error shapes first, then fall back to the generic toast. + // Example: + // if (error.response?.data?.message === "…") { + // toast.error(t("errors__…"), { toastId: "…" }); + // } else { + toast.error(t("errors__generic"), { toastId: "generic" }); + // } + }) + .finally(() => { + setIsLoading(false); + }); + } catch (error) { + // Yup validation errors ONLY. Do not toast — FieldHelperText renders them inline. + if (error instanceof ValidationError) { + setFormErrors(error.inner); + } + } + }, + [__formName__, setIsLoading, t], + ); + + const onValidate = useCallback(() => { + try { + if (__formName__Validated) { + __formName__Schema.validateSync(__formName__, { abortEarly: false }); + setFormErrors([]); + } + } catch (error) { + if (error instanceof ValidationError) { + setFormErrors(error.inner); + } + } + }, [__formName__, __formName__Validated]); + + return ( + <form className="flex-column" onSubmit={onSubmit}> + {/* Repeat this block per field. fieldNames MUST match the schema key exactly. */} + <div className="mb-md"> + <TextField + fullWidth + onBlur={onValidate} + autoComplete="off" + value={__formName__.fieldName} + onChange={(value) => + set__FormName__((prevState) => ({ ...prevState, fieldName: value })) + } + label={t("<namespace>__field_name")} + /> + <FieldHelperText fieldNames="fieldName" formErrors={formErrors} /> + </div> + + <Button className="mb-md" variant="contained" size="large" type="submit"> + {t("<namespace>__submit")} + </Button> + </form> + ); +} diff --git a/.github/skills/add-form/assets/formName.schema.ts.template b/.github/skills/add-form/assets/formName.schema.ts.template new file mode 100644 index 0000000..bea6676 --- /dev/null +++ b/.github/skills/add-form/assets/formName.schema.ts.template @@ -0,0 +1,27 @@ +import { object, string } from "yup"; +// Add more imports as needed: number, boolean, array, mixed, date, ref +// Cross-field validation example: import { object, ref, string } from "yup"; + +const __formName__Schema = object({ + // One entry per field. Field name MUST match the request interface + form state key. + // + // Rules: + // - .label(...) receives an i18n key, NOT human text. FieldHelperText translates it + // and injects it as {{ field }} in the error message. + // - Every validator that can fail carries a `validations__*` i18n key as its message. + // - Reuse existing keys when possible (validations__required, validations__min_characters, + // validations__max_characters). Add new ones via sheet2i18n. + // + // Example field: + // fieldName: string() + // .label("<namespace>__field_name") + // .required("validations__required") + // .min(8, "validations__min_characters"), + // + // Cross-field example (put "password" first so it's defined when referenced): + // passwordConfirm: string() + // .label("<namespace>__password_confirm") + // .oneOf([ref("password")], "validations__passwords_must_match"), +}); + +export default __formName__Schema; diff --git a/.github/skills/add-i18n-key/SKILL.md b/.github/skills/add-i18n-key/SKILL.md new file mode 100644 index 0000000..c6650b7 --- /dev/null +++ b/.github/skills/add-i18n-key/SKILL.md @@ -0,0 +1,328 @@ +--- +name: add-i18n-key +description: "Add or change a translation key in the web-react-skeleton React SPA. Use when: adding a translation, adding an i18n key, translating user-facing text, editing en.json / fr.json, adding a validation message, adding a toast/error copy, adding a route path in another language, adding a page title, adding plurals, adding interpolation, or running yarn sheet2i18n. Enforces the sheet2i18n workflow (never hand-edit locale JSONs — edit the Google Sheet, run yarn sheet2i18n, commit regenerated JSONs), the <namespace>__<snake_case_name> key convention, reserved keys (locale__key, routes__<page>, <page>__page_title), interpolation with {{ name }}, plurals via count, and the translate-in-parent / string-down rule." +argument-hint: "<key or feature> — e.g. 'register__sign_up' or 'sign-up button on the register page'" +--- + +# Add / Change an i18n Key + +Add a new translation key (or change an existing one) in the web-react-skeleton frontend, following the Google Sheet → `yarn sheet2i18n` → generated JSON pipeline. The locale JSONs are **generated**, not edited. + +## When to Use + +- User asks to "add a translation", "translate this text", "add an i18n key", "add a copy string", "add a validation message", "add an error toast", or "add a page title". +- User asks to add a route path in another language (`routes__<page>` key). +- User asks to add plurals or an interpolated string (`{{ name }}`). +- User asks to sync locales, regenerate JSONs, or run `yarn sheet2i18n`. +- User is about to render a hard-coded English string in JSX. +- User has just opened `frontend/src/assets/locales/en.json` or `fr.json` to edit it. + +Do **not** use this skill for: +- Wiring `useTranslation` in a new component with keys that already exist (just call `t("existing_key")`). +- Adding a whole new language (that's a bigger recipe — see [i18n.instructions.md](../../instructions/i18n.instructions.md#adding-a-new-locale)). +- Reordering keys or reformatting the JSONs — those changes are lost on the next regeneration. + +## The One Rule That Trumps Everything + +**Never hand-edit `frontend/src/assets/locales/en.json` or `fr.json`.** They are regenerated from a Google Sheet by `yarn sheet2i18n`. Any manual edit is silently overwritten on the next sync — the string ships once, then vanishes. + +If the user starts editing a locale JSON directly, **stop them** and redirect to the Sheet workflow below. + +## Inputs to Confirm + +Before doing anything, confirm with the user (ask only what's missing): + +1. **What the user-facing text is**, in the source language (usually English). E.g. `"Sign up"`. +2. **The other language(s)** — for this repo, French. If unknown, ask; do not machine-translate silently in the code path. +3. **Where the key will be consumed** — page name, component, form, toast, route path, page title. This picks the namespace. +4. **Whether the key needs interpolation** (`{{ name }}`, `{{ min }}`, etc.) and the placeholder names. +5. **Whether the key needs plurals** — is `count` variable? If yes, both the base and `_other` (and `_zero` / `_few` / `_many` for other languages) forms are needed. +6. **Whether an existing key already covers it** — always check `frontend/src/assets/locales/en.json` before proposing a new key. Reuse `errors__generic`, `validations__required`, `global__close`, etc. + +## The Workflow (Ground Truth) + +``` +Google Sheet (source of truth) + │ + │ yarn sheet2i18n (from frontend/) + ▼ +frontend/src/assets/locales/en.json + fr.json (generated — do not edit) + │ + │ imported by @shared/i18n + ▼ +t("<namespace>__<name>") in components / forms / toasts +``` + +**Every new / changed translation lives first in the Sheet.** Code and JSON follow. + +## Procedure + +Follow every step in order. + +### 1. Check if the key already exists + +Search `frontend/src/assets/locales/en.json` for an existing key before proposing a new one. Common reusable keys: + +| Key | Use for | +|---|---| +| `global__close`, `global__hide`, `global__clipboard_copy` | Generic UI actions. | +| `errors__generic` | Any unclassified failure toast. | +| `errors__invalid_credentials`, `errors__expired_session` | Existing auth errors. | +| `validations__required` | Any required field. | +| `validations__min_characters`, `validations__max_characters` | Length validators (interpolates `{{ min }}` / `{{ max }}`). | +| `locale__key`, `locale__switch_key`, `locale__switch` | Language switching — **never** create new variants of these. | + +If a suitable key exists → skip the Sheet edit, use the existing key in code, done. + +### 2. Pick the namespace + +Every key is `<namespace>__<snake_case_name>` — **double underscore** between namespace and name. The namespace matches a **Sheet tab**. Current tabs (see `frontend/src/sheet2i18n.config.cjs`): + +| Sheet tab / Namespace | Use for | Examples | +|---|---|---| +| `global` (Global tab) | App-wide UI text used on more than one page. | `global__close`, `global__hide` | +| `components` (Components tab) | Copy owned by a shared `@components/*` or `@containers/*` (e.g. cookie banner). | `cookie_banner__accept_all`, `cookie_modal__title` | +| `routes` (Routes tab) | Localized URL segments + the shared page-title suffix. | `routes__login`, `routes__home`, `routes__page_title` | +| `validations` (Validations tab) | Yup validation messages, with `{{ field }}` / `{{ min }}` / `{{ max }}` interpolation. | `validations__required`, `validations__max_characters` | +| `errors` (Errors tab) | User-facing error copy (used with `toast.error`). | `errors__generic`, `errors__invalid_credentials` | +| `<page>` (one tab per page: Home, Login, Uikit, …) | Copy scoped to a single page. Namespace = page folder name. | `home__welcome`, `login__sign_in`, `dashboard__page_title` | + +Decision rules: + +- **Used on one page** → page namespace (`home__…`, `login__…`). +- **Used across pages** → `global__…`. +- **Owned by a shared component/container** → `components` tab with a `<component>__…` namespace (e.g. `cookie_banner__…`). +- **Yup validation message** → always `validations__…`. +- **Toast/error copy** → always `errors__…`. +- **Localized URL segment** → always `routes__<page>` (the value is the URL slug for that language). +- **Page title (browser tab)** → always `<page>__page_title` (consumed by `Router.tsx`). + +If the target page has no Sheet tab yet, tell the user — a new tab plus a new URL entry in `sheet2i18n.config.cjs` are required before regeneration will pick it up. + +### 3. Pick the key name + +- Lowercase, `snake_case` after the namespace: `home__welcome`, not `home__Welcome` or `home__welcomeMessage`. +- Descriptive of the meaning, not the location — `register__sign_up` (button meaning), not `register__button_1`. +- Field labels: `<page>__<field>` (e.g. `register__email`). The namespace matches the **page** that hosts the form, not the form itself. +- For plurals, name the singular normally and add `_other` (and `_zero` / `_few` / `_many` if needed) siblings: `asset__share_modal_title` + `asset__share_modal_title_other`. +- For interpolation, name matches the object key you'll pass in code — case-sensitive, spaces required around the placeholder: `"{{ field }} is required."`. + +### 4. Reserved keys — never rename or remove + +Do not rename or remove any of these — they are consumed by the router / language switch / page-title logic and renaming them breaks the app: + +| Key | Consumed by | Effect if broken | +|---|---|---| +| `locale` | Human-readable language label. | UI display only. | +| `locale__key` | Every `route.paths[t("locale__key")]` call, `Router.tsx`, `<Helmet htmlAttributes>`. | Breaks all localized navigation. | +| `locale__switch` | Label on the language toggle. | UI display only. | +| `locale__switch_key` | `findRoute(...)` + `i18n.changeLanguage(...)` on language switch. | Breaks language switch. | +| `routes__<page>` | Every `<page>.route.tsx` builds its URL from this. | URL 404s after regeneration. | +| `<page>__page_title` | `Router.tsx` sets the tab title from this. | Tab title falls back to the raw key. | +| `routes__page_title` | Global app title suffix in the tab. | Tab title reads as raw key. | + +If a rename is genuinely required, do it in the Sheet, regenerate, and update every code site in the same commit. + +### 5. Tell the user exactly what to add to the Sheet + +Do not just say "add it to the Sheet." Produce a concrete instruction listing: + +- **Tab name** (from step 2). +- **Key** (from step 3). +- **English value**. +- **French value** (or ask the user). +- For plurals: the base row **and** the `_other` row. +- For interpolation: the exact `{{ name }}` placeholders as they appear in the value. + +Example message to the user: + +> Add the following rows to the Google Sheet (tab **Login**): +> | key | en | fr | +> |---|---|---| +> | `register__email` | Email address | Adresse e-mail | +> | `register__password` | Password | Mot de passe | +> | `register__sign_up` | Sign up | S'inscrire | +> +> Then run `yarn sheet2i18n` from `frontend/`. + +If the user cannot update the Sheet right now, offer two options: + +- **(a) Pause** the code change until keys land. Preferred when the target key is required for compilation (`routes__<page>`, `<page>__page_title`). +- **(b) Proceed** with the intended key names — the app will render the raw key string (i18next `returnNull: false`) until the JSONs regenerate. Acceptable for body copy, not for reserved keys. + +**Never** hand-edit `en.json` / `fr.json` as a workaround. + +### 6. Run `yarn sheet2i18n` + +Once the Sheet has been updated, run from `frontend/`: + +```sh +yarn sheet2i18n +``` + +Expected result: `frontend/src/assets/locales/en.json` and `fr.json` are updated in place. Diff them to confirm the new key is present in both locales with the expected values. + +If the diff shows an unexpected removal or reordering, do **not** revert manually — check the Sheet for a row that was deleted or moved. The generator's output is a mirror of the Sheet. + +### 7. Use the key in code + +Consumption patterns depend on where the key lands. Pick the matching one. + +#### 7a. In a component (default case) + +```tsx +import { useTranslation } from "react-i18next"; + +function Home() { + const { t } = useTranslation(); + return <Typography variant="h4">{t("home__welcome")}</Typography>; +} +``` + +Rules: + +- Import `useTranslation` **from `react-i18next`**, never from `i18next`. +- Call `t()` at render time. Never at module scope, class field, or store default. +- **Translate in the parent, pass a string to the child.** A child receives `children: ReactNode` or a `label: string` — never a `labelKey`. The only exception is a wrapper whose entire job is to look up a key by construction (e.g. `FieldHelperText`). +- No half-sentence concatenation (`t("common__hello") + " " + name`) — use interpolation instead. + +#### 7b. With interpolation + +Sheet value: `"{{ field }} is required."` (mind the spaces). + +```tsx +t("validations__required", { field: t("register__email") }); +``` + +- Placeholder names in the value are **case-sensitive** and include surrounding spaces (`{{ name }}`, not `{{name}}`). +- Values passed in can be already-translated strings, formatted numbers/currencies (`dayjs`, `formatCurrency`), or raw numbers/booleans. +- **Never** pass raw HTML — React escapes it. If you need markup, split the string and compose in JSX. +- For "field name inside a sentence" (Yup / FieldHelperText), translate the label key first, then pass the translated string as the placeholder. + +#### 7c. With plurals + +```tsx +t("asset__share_modal_title", { count: assets.length }); +``` + +- Do not branch with `assets.length === 1 ? t(...) : t("..._other")`. Use `count`. +- Add the `_other` (and `_zero` / `_few` / `_many` if the target language needs it) rows in the Sheet alongside the base — the generator produces the sibling keys. + +#### 7d. In a toast + +```tsx +toast.error(t("errors__invalid_credentials"), { toastId: "invalid-credentials" }); +toast.error(t("errors__generic"), { toastId: "generic" }); +``` + +- Always pass a **stable `toastId`** (a slug matching the key name minus the namespace is a fine default). Without it, a burst of failed requests stacks N identical toasts. +- Reuse `errors__generic` for unclassified failures. Add a new `errors__…` key only when the copy is genuinely distinct. + +#### 7e. In a Yup schema (validation) + +Validation messages **must** be `validations__*` keys — `FieldHelperText` translates them by looking up the key and interpolating the field label: + +```ts +import { object, string } from "yup"; + +const registerSchema = object({ + email: string() + .label("register__email") // label = i18n key, NOT human text + .required("validations__required") // message = i18n key + .email("validations__invalid_email"), +}); + +export default registerSchema; +``` + +- `.label(...)` receives the i18n key of the field name. `FieldHelperText` translates it into `{{ field }}`. +- Every validator that can fail carries a `validations__*` message key. No `.required()` without an argument. +- Never inline plain-English validation strings — the message goes through `t()` inside `FieldHelperText`. + +#### 7f. As a localized route path + +`routes__<page>` values are URL slugs (one per language). Each `<page>.route.tsx` reads them by importing the locale JSONs directly (**not** via `t()` — the router runs before the React tree mounts): + +```tsx +// dashboard.route.tsx +import en from "@assets/locales/en.json"; +import fr from "@assets/locales/fr.json"; +import { IRoute } from "@routes/interfaces/IRoute"; +import { lazy } from "react"; + +const dashboardRoute: IRoute = { + name: "dashboard__page_title", + component: lazy(() => import("./withAuthDashboard")), + paths: { + en: `/${en.locale__key}/${en.routes__dashboard}`, + fr: `/${fr.locale__key}/${fr.routes__dashboard}`, + }, +}; + +export default dashboardRoute; +``` + +Adding a new page → add the matching `routes__<page>` row in the Sheet **before** the route file references it, otherwise `en.routes__dashboard` is `undefined` and TypeScript fails the build. `name` is the raw i18n key string — `Router.tsx` calls `t()` on it at render time. + +#### 7g. As a page title + +Add `<page>__page_title` in the Sheet and set it as the `name` field of the route (see 7f). `Router.tsx` translates it for the browser tab title (suffixed with `routes__page_title`). + +### 8. Verify + +From `frontend/`: + +1. **Confirm the keys are in both JSONs** — grep the new key in `assets/locales/en.json` and `fr.json`. +2. **`yarn lint:scripts`** — catches typos in `t("…")` only if the key is referenced via a const; freeform strings pass. Do not rely on it for i18n coverage. +3. **`yarn build`** — full type check. Missing `routes__<page>` or `<page>__page_title` keys still build (i18next returns the raw key) but produce visible bugs; walk the affected pages. +4. **`yarn dev`** — render the page in both languages: + - Switch languages via the toggle. The new text updates in place. + - Placeholders (`{{ name }}`) render substituted, not literal. + - Plurals switch between singular / `_other` as `count` changes. + - No key string leaks into the UI (a visible `home__welcome` means the JSON regeneration was skipped). +5. **Commit the regenerated JSONs together with the code change** — never in a separate PR. Reviewers rely on the JSON diff to see what strings were added. + +## Completion Checklist + +Before reporting done, verify all apply: + +- [ ] The key is present in `frontend/src/assets/locales/en.json` **and** `fr.json`, with the expected values. +- [ ] The JSONs were regenerated via `yarn sheet2i18n` — no hand edits. +- [ ] Key follows `<namespace>__<snake_case_name>`; namespace matches a Sheet tab. +- [ ] Reserved keys were not renamed or removed. +- [ ] Interpolation placeholders in the value match the keys passed in `t(key, { … })`, spaces and case included. +- [ ] Plurals use `count`, with sibling `_other` (and `_zero` / `_few` / `_many` as needed) in the Sheet. +- [ ] Toasts pass a stable `toastId`. +- [ ] Validation messages use `validations__*` keys, and `.label(...)` in the Yup schema is a key (not human text). +- [ ] Components call `t()` at render time; children receive translated strings, not keys. +- [ ] The regenerated JSONs are staged in the same commit as the code that uses the new keys. +- [ ] The page renders correctly in both `en` and `fr` after a language switch. + +## Anti-patterns to Reject + +- Hand-editing `frontend/src/assets/locales/en.json` or `fr.json` — regenerate via `yarn sheet2i18n`. +- Adding a key in code that doesn't yet exist in the JSON without telling the user. It falls back to the raw key string and ships unnoticed. +- Renaming or removing `locale__key`, `locale__switch_key`, `routes__<page>`, `<page>__page_title`, or `routes__page_title` without updating every consumer in the same commit. +- Machine-translating French inline in code as a fallback. Route through the Sheet. +- Concatenating translated fragments (`t("hello") + " " + name`) — use `{{ name }}`. +- Conditional `t()` for plurals (`count === 1 ? t(...) : t("..._other")`) — use `count`. +- `.replace("{name}", value)` on a translated string — use i18next interpolation. +- Storing `t("…")` at module scope, class field, or store default — it runs before i18next initializes. +- Translating twice: `t(t("foo"))`. The child was handed a key when it should have been handed a string. +- Passing `labelKey` / `translationKey` props to a child component — parent translates, child receives a string. +- `toast.error(t("errors__…"))` without a `toastId` — duplicate toasts stack. +- Yup `.label("Email address")` or `.required("Required")` — labels and messages must be i18n keys, not human strings. +- Reading the current locale from `navigator.language`, `location.pathname.split("/")[1]`, or a store field — use `t("locale__key")`. +- Adding a namespace prefix in the call (`t("common:hello")`) — the app uses a single default namespace. +- HTML in a translation value — split the string and compose in JSX. +- Bypassing `i18n.changeLanguage` on language switch (only editing the URL) — the runtime language stays stale. +- Committing code that uses a new key without the regenerated JSONs — reviewers can't verify the copy. + +## References + +- [i18n.instructions.md](../../instructions/i18n.instructions.md) — the full rules this skill enforces. +- [routing-and-auth.instructions.md](../../instructions/routing-and-auth.instructions.md) — how `locale__key`, `locale__switch_key`, and `routes__<page>` power localized URLs; `findRoute` for language switching. +- [forms.instructions.md](../../instructions/forms.instructions.md) — how Yup validation keys map through `FieldHelperText`. +- [services-api.instructions.md](../../instructions/services-api.instructions.md) — `toast.error(t("errors__…"))` with stable `toastId`. +- [react.instructions.md](../../instructions/react.instructions.md) — the "translate in parent, pass a string down" rule. +- `add-form`, `add-page`, `add-service` skills — all three depend on this workflow when they introduce new user-facing text. diff --git a/.github/skills/add-icon/SKILL.md b/.github/skills/add-icon/SKILL.md new file mode 100644 index 0000000..34694f4 --- /dev/null +++ b/.github/skills/add-icon/SKILL.md @@ -0,0 +1,379 @@ +--- +name: add-icon +description: "Add a hand-wrapped inline SVG icon to the web-react-skeleton React SPA. Use when: adding a new icon, wrapping an SVG, creating a file under frontend/src/app/icons/, importing from @icons/*, replacing an @mui/icons-material / react-icons / lucide-react / heroicons / font-awesome import, adding an accordion caret / close / add / logout / cookie icon, or theming an SVG fill via the sx prop. Enforces the flat folder layout (icons/<Name>.tsx + shared IIcon.ts), the IIcon contract with no per-icon prop widening, the mandatory <title>{alt}, sx-callback + theme.palette.* fill (never hex / named color / currentColor), matched width/height defaults from the viewBox, and the no-icon-library rule." +argument-hint: " — PascalCase, e.g. 'ChevronRounded', 'SearchOutlined', 'UserCircleIcon'" +--- + +# Add an SVG Icon + +Wrap a raw SVG as a repo icon under `frontend/src/app/icons/.tsx` following the project's `IIcon` contract and the `sx` + theme-token fill pattern. + +## When to Use + +- User asks to "add an icon", "wrap this SVG", "create a `Icon>`", "add a Chevron icon", etc. +- User pastes SVG markup and wants it as a reusable React component. +- User writes `import X from "@mui/icons-material/X"` (or `react-icons`, `lucide-react`, `heroicons`, `font-awesome`) — those imports are banned; wrap the SVG instead. +- User needs an icon inside a `Button`, `IconButton`, `AccordionSummary`, etc. and no icon under `@icons/*` matches. + +Do **not** use this skill for: +- Editing an existing icon — just edit it in place following the same template. +- Adding an icon-library dependency (`@mui/icons-material`, `react-icons`, `lucide-react`, `heroicons`, Font Awesome). The repo has zero icon-library imports and that must stay true. +- Wrapping a full interactive control (e.g. an icon-button with click handlers) — that's a component under `@components/*`. See the `add-mui-component` skill. +- Adding a raster / bitmap asset (PNG / JPG / WebP) — those go under `frontend/src/assets/images/`, not `@icons/*`. +- Registering icons in the UiKit page — icons are **assets**, not UI components, and are intentionally not listed there today. + +## Inputs to Confirm + +Before starting, confirm with the user (ask only what's missing): + +1. **Icon name** — PascalCase, describes the shape or action. Follow the source's suffix if there's a variant family (`Rounded`, `Outlined`, `Sharp`, `Filled`). Examples: `AddRounded`, `ExternalLinkOutlined`, `CookieIcon`, `CaretIcon`. If the source is from Material Symbols / Feather / a design file, mirror that name. +2. **SVG source** — the raw `` markup or a file path. Must include a `viewBox` (or explicit `width` / `height` you can derive one from). +3. **Fill mode** — one of: + - **Monochrome** (single color for the whole graphic) → use the monochrome-shortcut template (`sx` on ``, no `fill="none"`). + - **Multi-color** (different fills per shape) → use the per-path template (`sx` on each `` / `` / ``, `fill="none"` on ``). +4. **Palette token** — pick from what's already in use before inventing a new one: + - `theme.palette.common.white` — icons rendered on colored buttons. + - `theme.palette.grey[800]` — neutral UI chrome. + - `theme.palette.primary.main` — brand-colored accents. + If none fit, add a new token to [themes/palette.ts](../../../frontend/src/themes/palette.ts) / [themes/variables.ts](../../../frontend/src/themes/variables.ts) and augment [material-ui-pigment-css.d.ts](../../../frontend/src/material-ui-pigment-css.d.ts) first — do **not** paste raw hex. +5. **Alt text default** — the spaced version of the PascalCase name (`AddRounded` → `"Add Rounded"`, `CookieIcon` → `"Cookie Icon"`). Consumers override it when the icon is the sole affordance in an interactive control. + +If the user just says "add a chevron icon", pick the closest existing pattern (`CaretIcon` for a monochrome caret, `AddRounded` for a single-path filled monochrome, `CookieIcon` for a multi-shape monochrome, `ExternalLinkOutlined` for a non-24 sized icon) and confirm in the summary. + +## The Icon Pattern (Ground Truth) + +Every icon is exactly one file: + +``` +frontend/src/app/icons/ +├── IIcon.ts # shared props interface — .ts (no JSX), never edit for per-icon needs +└── .tsx # one file per icon, PascalCase, default export +``` + +- **Flat folder.** No subfolders per icon or per category. +- **No barrel `index.ts`.** Consumers import each icon by name: `import CaretIcon from "@icons/CaretIcon";`. +- **`IIcon.ts` is `.ts`**, not `.tsx` — no JSX in the shared props file. Never widen it for a single icon (see the "local extension" mistake below). +- **Path alias `@icons`** everywhere outside the `icons/` folder. Inside `icons/`, the sibling import `import IIcon from "./IIcon";` is the one relative import allowed. + +The `IIcon` contract — every icon accepts exactly this shape, no more, no less: + +```ts +export default interface IIcon { + className?: string; + color?: string; + width?: number; + height?: number; + alt?: string; +} +``` + +Canonical monochrome shape (from [icons/CookieIcon.tsx](../../../frontend/src/app/icons/CookieIcon.tsx)): + +```tsx +import IIcon from "./IIcon"; + +export default function CookieIcon({ + className, + width = 24, + height = 25, + alt = "Cookie Icon", +}: IIcon) { + return ( + ({ + fill: theme.palette.primary.main, + })} + xmlns="http://www.w3.org/2000/svg" + > + {alt} + + + + ); +} +``` + +Canonical per-path multi-color shape (from [icons/AddRounded.tsx](../../../frontend/src/app/icons/AddRounded.tsx)): + +```tsx +import IIcon from "./IIcon"; + +export default function AddRounded({ + className, + width = 24, + height = 24, + alt = "Add Rounded", +}: IIcon) { + return ( + + {alt} + ({ + fill: theme.palette.common.white, + })} + /> + + ); +} +``` + +## Procedure + +Follow every step in order. Do not skip. + +### 1. Verify the icon doesn't already exist + +- List [frontend/src/app/icons/](../../../frontend/src/app/icons) and confirm no file matches the intended name (case-insensitively) or the same shape under a different name (grep for a distinctive fragment of the `d=` path if the user pasted SVG). +- Grep the codebase for banned icon-library imports the user might be replacing: `@mui/icons-material`, `react-icons`, `lucide-react`, `heroicons`, `@fortawesome`. Any hit outside `package.json` is a migration target for after step 5. +- If the icon already exists, stop and edit it in place — do not create a second file. + +### 2. Optimize the SVG source + +Before writing the file, strip the raw markup down: + +1. Run through SVGO or an equivalent optimizer (Figma-exported SVGs are the worst offenders). +2. Keep only: `viewBox`, `` / `` / `` / `` geometry, and geometry-only attributes (`d`, `cx`, `cy`, `r`, `x`, `y`, `width`, `height`, `fillRule`, `clipRule`, `strokeWidth` if meaningful). +3. Strip: `id`, `class`, `style`, inline `fill` / `stroke`, `stroke-width` when it's `0` or the default, `data-*`, XML comments, `` / `` / `` unless they carry real geometry, ``, `` from the source (you'll add your own). +4. Flag `` with gradients, ``, ``, or embedded `