# Guide d'implémentation — Frontend > Version : 0.1 · Dernière maj : 2026-05-05 > Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-017 (auth). Ce document est le **guide pratique d'implémentation du SPA**. Il complète `architecture.md` (qui décrit le **quoi**) en expliquant le **comment** : commandes exactes, snippets de config, conventions de dossier. **À lire avant** : - `/CLAUDE.md` — contexte top-level - `/docs/produit.md` — flows utilisateur, IN/OUT V1 - `/docs/marque.md` — palette, typo, voix, do/don't - `/docs/wireframes-mvp.html` — les 13 écrans MVP avec annotations - `/docs/tech/architecture.md` — vue d'ensemble du système --- ## 1. Vue d'ensemble L'app web (`apps/web/`) est un SPA React 19 buildé par Vite, qui consomme l'API AdonisJS `apps/api/` via un client HTTP type-safe (Tuyau). Le routing client est géré par **TanStack Router** (file-based, type-safe), le state serveur par **TanStack Query**, le styling par **Tailwind CSS v4** avec les tokens de marque issus de `marque.md`. **Périmètre V1** : 13 écrans listés dans `wireframes-mvp.html`. Auth Bearer (cf. ADR-017) avec refresh token httpOnly cookie. Mobile responsive, pas d'app native. **Hors scope V1** : SSR (pas nécessaire pour un SaaS B2B authentifié), i18n (FR uniquement), PWA offline (nice-to-have V2). --- ## 2. Dépendances ### Bootstrap du workspace À exécuter à la racine du monorepo (après avoir créé `pnpm-workspace.yaml`) : ```bash mkdir -p apps/web && cd apps/web pnpm create vite@latest . --template react-ts ``` Choix Vite : `react-ts` (TypeScript natif). ### Dépendances runtime ```bash # Routing & state serveur pnpm add @tanstack/react-router @tanstack/react-query @tanstack/react-query-devtools # Tooling Vite pour TanStack Router (file-based) pnpm add -D @tanstack/router-plugin # Client HTTP typé pour AdonisJS pnpm add @tuyau/client # UI primitives & icônes pnpm add lucide-react pnpm add clsx tailwind-merge # Validation côté client (réutilise schemas Zod de packages/shared) pnpm add zod # Notifications/toasts pnpm add sonner # Dates (formatage français) pnpm add date-fns ``` ### Dépendances dev ```bash # Tailwind v4 pnpm add -D tailwindcss @tailwindcss/vite # TS strict + lint pnpm add -D typescript@latest @types/react @types/react-dom pnpm add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser pnpm add -D eslint-plugin-react-hooks eslint-plugin-react-refresh # Tests pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom # Type-checking strict pnpm add -D tsc-files ``` ### Référence au package shared Dans `apps/web/package.json` : ```json { "dependencies": { "@rubis/shared": "workspace:*" } } ``` Permet d'importer les types et schemas Zod depuis `packages/shared/` sans publication npm. --- ## 3. Tailwind CSS v4 + tokens de marque Tailwind v4 utilise une configuration CSS-first (plus de `tailwind.config.js` requis). Les tokens de marque issus de `marque.md` deviennent des CSS variables. ### Installation `vite.config.ts` : ```ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { TanStackRouterVite } from '@tanstack/router-plugin/vite' export default defineConfig({ plugins: [ TanStackRouterVite({ routesDirectory: 'src/routes' }), react(), tailwindcss(), ], }) ``` ### Tokens de marque dans `src/styles/app.css` ```css @import "tailwindcss"; @theme { /* Couleurs rubis */ --color-rubis: #9F1239; --color-rubis-deep: #771328; --color-rubis-light: #C9415C; --color-rubis-glow: #FBE4EA; /* Neutres chauds */ --color-cream: #FAF7F2; --color-cream-2: #F5EFE7; --color-line: #E8E0D6; --color-ink: #1A1410; --color-ink-2: #4F4640; --color-ink-3: #8A7F76; /* Typographies */ --font-display: "Bricolage Grotesque", -apple-system, BlinkMacSystemFont, sans-serif; --font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; } /* Globals */ body { font-family: var(--font-sans); background: var(--color-cream); color: var(--color-ink); font-feature-settings: "ss01", "cv11"; -webkit-font-smoothing: antialiased; } /* Selection */ ::selection { background: var(--color-rubis); color: white; } ``` Importé dans `main.tsx` : `import './styles/app.css'`. ### Polices Google Fonts `index.html` : ```html ``` ### Usage typique ```tsx

Bonjour Arthur

``` ### Règles de marque appliquées - **Pas de `bg-white` en pleine page** → toujours `bg-cream` (#FAF7F2) - **Pas de `bg-black` ni `text-black`** → utiliser `bg-ink` et `text-ink` (#1A1410) - **Le rubis est rare** → un seul aplat fort par écran maximum - **Italique rubis** sur le mot-clé d'un titre : `` - **Le ◆** est un SVG custom, jamais une icône Lucide (cf. `marque.md`) --- ## 4. TanStack Router — routing file-based ### Pourquoi file-based Le routing file-based est **type-safe nativement** (les params, search params, et loaders sont inférés depuis les fichiers), il évite la déclaration manuelle d'un router central, et il s'aligne avec la structure d'écrans du wireframe. ### Structure des routes Référence : les 13 écrans dans `wireframes-mvp.html`. ``` apps/web/src/routes/ ├── __root.tsx # Layout global, providers, AuthGate ├── login.tsx # 1.2 Connexion ├── signup.tsx # 1.1 Inscription ├── _onboarding/ # Layout onboarding (sans sidebar) │ ├── _onboarding.tsx │ ├── compte.tsx # 1.3 step 1 │ ├── entreprise.tsx # 1.3 step 2 │ └── signature.tsx # 1.3 step 3 └── _app/ # Layout app authentifiée ├── _app.tsx # Layout : sidebar + topbar + tab bar mobile ├── index.tsx # 4.1 Dashboard ├── factures.tsx # 2.4 Liste filtrable ├── factures.$id.tsx # 4.2 Détail facture (timeline) ├── factures.import.$batchId.tsx # 2.2 Vérification OCR ├── plans.tsx # 3.1 Bibliothèque ├── plans.$slug.tsx # 3.2 Éditeur (cadence + templates) ├── clients.tsx # liste clients └── parametres.tsx # paramètres compte ``` Les routes commençant par `_` sont des **layout routes** (n'ajoutent pas de segment URL). ### Configuration root `src/routes/__root.tsx` : ```tsx import { createRootRoute, Outlet } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { Toaster } from 'sonner' export const Route = createRootRoute({ component: RootLayout, notFoundComponent: NotFound, }) function RootLayout() { return ( <> {import.meta.env.DEV && ( <> )} ) } function NotFound() { return
Page introuvable.
} ``` ### Auth guard sur le layout `_app` `src/routes/_app/_app.tsx` : ```tsx import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' import { AppLayout } from '@/components/layout/AppLayout' import { authStore } from '@/lib/auth' export const Route = createFileRoute('/_app')({ beforeLoad: async ({ location }) => { if (!authStore.isAuthenticated()) { throw redirect({ to: '/login', search: { redirect: location.href }, }) } }, component: AppLayoutComponent, }) function AppLayoutComponent() { return ( ) } ``` ### Search params type-safe (filtres factures) `src/routes/_app/factures.tsx` : ```tsx import { z } from 'zod' import { createFileRoute } from '@tanstack/react-router' const filterSchema = z.object({ statut: z.enum(['toutes', 'a_relancer', 'en_relance', 'encaissees', 'litige']).optional(), q: z.string().optional(), page: z.number().int().min(1).optional().default(1), }) export const Route = createFileRoute('/_app/factures')({ validateSearch: filterSchema, component: FacturesPage, }) ``` → Les filtres sont dans l'URL, partageables par lien, persistés au reload, type-safe à l'usage : `Route.useSearch()` retourne le bon type. --- ## 5. TanStack Query — state serveur ### Provider racine `src/main.tsx` : ```tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider, createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, // 30s par défaut, ajusté par query gcTime: 5 * 60_000, // 5 min en cache retry: 1, refetchOnWindowFocus: false, }, mutations: { retry: 0, }, }, }) const router = createRouter({ routeTree, context: { queryClient }, defaultPreload: 'intent', }) declare module '@tanstack/react-router' { interface Register { router: typeof router } } createRoot(document.getElementById('root')!).render( ) ``` ### Convention queryKeys Dans `src/lib/queryKeys.ts` : ```ts export const queryKeys = { me: () => ['me'] as const, invoices: { all: () => ['invoices'] as const, list: (filters: InvoiceFilters) => ['invoices', 'list', filters] as const, detail: (id: string) => ['invoices', 'detail', id] as const, }, plans: { all: () => ['plans'] as const, detail: (slug: string) => ['plans', 'detail', slug] as const, }, clients: { all: () => ['clients'] as const, }, dashboard: { kpis: () => ['dashboard', 'kpis'] as const, activity: () => ['dashboard', 'activity'] as const, }, } as const ``` → Permet d'invalider précisément après une mutation : `queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() })`. ### Patterns d'invalidation après mutation ```ts const markPaidMutation = useMutation({ mutationFn: (id: string) => api.invoices({ id }).markPaid.$post(), onSuccess: (_, id) => { queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() }) queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() }) toast.success('Facture marquée encaissée. + 1 rubis.') }, }) ``` --- ## 6. Tuyau — client HTTP typé pour AdonisJS [Tuyau](https://github.com/Julien-R44/tuyau) est l'équivalent tRPC pour AdonisJS. Il génère un client TS qui connaît toutes les routes API, leurs payloads, et leurs réponses — depuis le code Adonis lui-même. ### Côté API (à faire dans `apps/api/`) ```bash cd apps/api pnpm add @tuyau/core node ace add @tuyau/core ``` Configuration : annoter les routes Adonis avec un nom (utilisé pour l'autocomplete TS) : ```ts // apps/api/start/routes.ts router.group(() => { router.post('/auth/login', '#controllers/auth_controller.login').as('auth.login') router.get('/me', '#controllers/me_controller.show').as('me.show').use(middleware.auth()) router.get('/invoices', '#controllers/invoices_controller.index') .as('invoices.index').use(middleware.auth()) router.post('/invoices', '#controllers/invoices_controller.store') .as('invoices.store').use(middleware.auth()) router.get('/invoices/:id', '#controllers/invoices_controller.show') .as('invoices.show').use(middleware.auth()) // ... }).prefix('/api/v1') ``` Génération du client typé : ```bash node ace tuyau:generate ``` Crée `.adonisjs/api.ts` qui décrit toutes les routes en types TS. Ce fichier est versionné OU regénéré au build (à choisir). ### Côté SPA (`apps/web/`) ```bash cd apps/web pnpm add @tuyau/client ``` `src/lib/api.ts` : ```ts import { createTuyau } from '@tuyau/client' import { api } from '../../../api/.adonisjs/api' // import des types depuis l'API export const tuyau = createTuyau({ api, baseUrl: import.meta.env.VITE_API_URL, // ex. https://api.rubis-sur-l-ongle.fr credentials: 'include', // envoie les cookies (refresh token) headers: () => ({ Authorization: authStore.token ? `Bearer ${authStore.token}` : '', }), }) ``` ### Intégration avec TanStack Query ```tsx import { useQuery } from '@tanstack/react-query' import { tuyau } from '@/lib/api' import { queryKeys } from '@/lib/queryKeys' export function useInvoices(filters: InvoiceFilters) { return useQuery({ queryKey: queryKeys.invoices.list(filters), queryFn: async () => { const { data, error } = await tuyau.api.v1.invoices.$get({ query: filters }) if (error) throw error return data // typé depuis le controller Adonis }, }) } ``` → **Zéro DTO manuel**, **autocomplete partout**, **erreur de compilation si l'API change** sans MAJ du SPA. Le contrat API ↔ web est verrouillé par TS. ### Génération automatique au dev Ajouter un script `apps/api/package.json` : ```json { "scripts": { "tuyau:watch": "node ace tuyau:generate --watch" } } ``` → Pendant le dev, modifier une route API régénère les types instantanément côté SPA. --- ## 7. Auth Bearer + refresh token (cf. ADR-017) ### Auth store (en mémoire, pas localStorage) `src/lib/auth.ts` : ```ts type AuthState = { token: string | null user: User | null } class AuthStore { private state: AuthState = { token: null, user: null } private listeners = new Set<() => void>() get token() { return this.state.token } get user() { return this.state.user } isAuthenticated() { return this.state.token !== null } setSession(token: string, user: User) { this.state = { token, user } this.notify() } clear() { this.state = { token: null, user: null } this.notify() } subscribe(fn: () => void) { this.listeners.add(fn) return () => this.listeners.delete(fn) } private notify() { this.listeners.forEach(fn => fn()) } } export const authStore = new AuthStore() ``` → Le token vit en mémoire. Au refresh de la page, il est perdu mais récupérable via `/auth/refresh` (qui lit le cookie httpOnly). ### Bootstrap session au boot du SPA `src/main.tsx` (avant le render) : ```ts async function bootstrap() { try { const { data } = await tuyau.api.v1.auth.refresh.$post() if (data) authStore.setSession(data.accessToken, data.user) } catch { // pas de refresh valide, rester anonyme } } bootstrap().then(() => render()) ``` → Si l'utilisateur a déjà une session valide (cookie refresh non expiré), le SPA récupère un access token avant le 1er render. Pas d'écran flash de login. ### Auto-refresh sur 401 Intercepteur dans `tuyau` (à customiser) ou dans un wrapper `api()` qui retry sur 401 après un refresh silent. --- ## 8. Pages à construire Référence visuelle : `/docs/wireframes-mvp.html`. Référence brand : `/docs/marque.md`. | # | Route | Wireframe | Priorité | Notes | |---|---|---|---|---| | 1 | `/login` | 1.2 | P0 | Email + password + Google SSO. | | 2 | `/signup` | 1.1 | P0 | 2 champs + Google SSO. | | 3 | `/onboarding/compte` | 1.3 step 1 | P0 | Préfilled email après signup. | | 4 | `/onboarding/entreprise` | 1.3 step 2 | P0 | Wizard avec chips volume mensuel. | | 5 | `/onboarding/signature` | 1.3 step 3 | P0 | Signature email pour les relances. | | 6 | `/_app/` (Dashboard) | 4.1 | P0 | Hero rubis, KPIs, activité du jour. | | 7 | `/_app/factures` | 2.4 | P0 | Liste + chips de filtre + actions en lot. | | 8 | `/_app/factures` (empty) | 2.1 | P0 | Dropzone + drag&drop. | | 9 | `/_app/factures/import/$batchId` | 2.2 | P0 | Split PDF / formulaire OCR. | | 10 | (modal) | 2.3 | P1 | Saisie manuelle. | | 11 | `/_app/factures/$id` | 4.2 | P0 | Timeline + sidepanel client + notes. | | 12 | `/_app/plans` | 3.1 | P0 | Cards 4 plans pré-fournis. | | 13 | `/_app/plans/$slug` | 3.2 | P0 | Éditeur cadence + email avec variables chips. | | 14 | (storyboard 3 clics) | 3.3 | — | Concept landing, pas un écran applicatif. | | 15 | Mobile dashboard | 4.3 | P0 | Responsive, pas une route séparée. | **Composants UI partagés à factoriser tôt** : - `` — gem ◆ + wordmark "Rubis" / "Rubis Sur l'Ongle" - `