rubis/docs/tech/frontend.md
ordinarthur 9eaac0c7ef docs(audit-2/3): aligner doc tech sur le code livré
Audit cross-doc/code, batch tech : architecture, backend, frontend,
dev-setup. Corrige les claims qui pouvaient induire un dev en erreur
(noms de services K3s, hostnames Traefik, Tuyau, queue wrapper,
seeders, env vars, polices).

architecture.md
- Composants : status « À écrire » → «  Déployé » (apps/web,
  apps/api, packages/shared) ; ajout Redis Deployment K3s ; OCR =
  Mistral choisi ; mail = Resend (sortant) + OVH MX (entrant)
  validés
- §7.5 Pods K3s : noms réels (rubis-api / rubis-web / rubis-landing
  / rubis-redis, pas de *-svc) ; pas d'IngressRoute api.rubis.pro
  (l'API est servie via app.rubis.pro/api/* proxifié par nginx du
  pod web) ; PG/MinIO en URL directe dans la ConfigMap, pas de
  Service ExternalName
- §10 Décisions en attente : ADRs 019-024 mises à jour
  (tranchées / obsolètes), suppression du wording « à venir » pour
  les choix déjà figés dans le code

backend.md
- Note de cohérence en tête : pointe vers start/routes.ts comme
  source de vérité de la surface API (~80 routes — Stripe,
  Demo, AI, Microsoft SSO, admin blog, posts publics, KPIs
  timeseries) que cette doc n'inventorie pas exhaustivement
- §1 Vue d'ensemble : Tuyau marqué « non utilisé en pratique »
  (présent en deps mais zéro import côté SPA), partage de types
  via packages/shared. OCR Mistral choisi. Mail Resend choisi.
  BullMQ direct (workers inline pod API). Sentry ADR-024.
- §2 Stack : queue = BullMQ direct (pas @rlanz/bull-queue, qui
  n'est pas installé) ; type-sharing = packages/shared
- §2 Dépendances : remplacé la todo-list pré-livraison par la
  liste réelle des packages dans apps/api/package.json
- §3 Repo layout : `database/factories/` (dossier) → `factories.ts`
  (mono-fichier) ; `database/seeders/{default_plans,demo_data}` →
  inexistants, services à la place
- §13.2 Jobs : ProcessOcrJob + RecomputeKpisJob retirés
  (n'existent pas — OCR synchrone via services/import_batch.ts,
  KPIs calculés on-the-fly). Liste des jobs réels :
  send_relance, send_checkin, send_payment_thanks
- env vars : MINIO_* → S3_* (cf. .env.example + manifest k3s) ;
  bucket prod = rubis-prod-invoices

frontend.md
- Note de cohérence en tête : Tuyau pas utilisé, tokens dans
  packages/ui (pas inline), polices @fontsource-variable (pas
  Google Fonts via <link>)
- §1 Vue d'ensemble : client API = fetch minimaliste dans
  apps/web/src/lib/api.ts ; périmètre livré = ~15 routes _app/
- §3 Polices : section Google Fonts → @fontsource-variable
  (avec note preload woff2 critique sur la landing Astro)
- §4 Routes : arbo `_onboarding/` (faux) → `onboarding/`
  (réel, segment URL) + ajout admin.blog*, clients_.$id, insights,
  parametres_.abonnement, plans_.nouveau, factures_.import
- §6 Tuyau : section marquée « historique, non utilisé en V1 »
  avec note explicative en tête
- §10 env vars : VITE_API_URL=https://api.rubis.pro → vide
  (proxifié same-origin par nginx) + ajout VITE_USE_MOCKS,
  VITE_SENTRY_DSN_WEB, VITE_APP_VERSION

dev-setup.md
- Mailhog → Mailpit (3 occurrences) — c'est ce qui tourne dans
  docker-compose.dev.yml

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:13:44 +02:00

28 KiB

Guide d'implémentation — Frontend

Version : 0.2 · Dernière maj : 2026-05-09 Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-017 (auth), ADR-024 (Sentry).

⚠️ Note de cohérence (audit 2026-05-09) : ce guide a été rédigé en phase de planification. Plusieurs choix ont évolué :

  • Tuyau N'EST PAS utilisé côté SPA. Le client API vit dans apps/web/src/lib/api.ts (fetch + ApiError custom). Le partage de types entre API et SPA passe par packages/shared (Zod + types TS), pas par un client typed-RPC. Les sections §6 et §7 ci-dessous, qui décrivent l'install et l'usage de Tuyau, sont historiques — gardées pour mémoire mais non applicables au code livré.
  • Les tokens de design vivent dans packages/ui/src/styles/{tokens,base}.css, pas inline dans apps/web/src/styles/app.css comme la doc l'écrit.
  • Les polices sont self-hosted via @fontsource-variable/{bricolage-grotesque,inter}, pas via <link> Google Fonts.

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 — wireframes low-fi initiaux (13 écrans, à jour partiellement)
  • /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 fetch() minimaliste dans apps/web/src/lib/api.ts. 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 packages/ui (consommé en workspace).

Périmètre V1 livré : ~15 routes _app/ (dashboard, factures, clients, plans, paramètres, abonnement, insights, admin/blog) + onboarding 3 étapes + auth (login, signup, reset-password, callbacks SSO Google/Microsoft). 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) :

mkdir -p apps/web && cd apps/web
pnpm create vite@latest . --template react-ts

Choix Vite : react-ts (TypeScript natif).

Dépendances runtime

# 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

# 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 :

{
  "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 :

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

@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 self-hosted via @fontsource-variable

Les fonts sont bundlées au build (pas de fetch Google Fonts au runtime → pas de FOUT, pas de fuite RGPD vers fonts.googleapis.com).

pnpm add @fontsource-variable/bricolage-grotesque @fontsource-variable/inter
/* apps/web/src/styles/app.css (ou packages/ui/src/styles/base.css selon où tu importes) */
@import "@fontsource-variable/bricolage-grotesque";
@import "@fontsource-variable/inter";

Bonus prod : sur la landing Astro, on preload les woff2 latin critiques dans le <head> pour casser la chaîne HTML→CSS→fonts du critical path (cf. apps/landing/src/layouts/Layout.astro).

Usage typique

<button className="bg-rubis text-white hover:bg-rubis-deep px-4 py-2 rounded-md font-medium">
  Démarrer
</button>

<h1 className="font-display text-4xl tracking-tight">
  Bonjour Arthur
</h1>

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 : <em className="italic text-rubis">
  • 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, signup.tsx, reset-password.tsx, accept-invite.tsx
├── auth/                               # callbacks SSO Google + Microsoft
├── onboarding.tsx                      # layout onboarding (segment URL /onboarding)
├── onboarding/                         # 3 étapes inscription post-signup
│   ├── compte.tsx
│   ├── entreprise.tsx
│   ├── signature.tsx
│   └── index.tsx
└── _app/                               # Layout app authentifiée (group route, pas de segment)
    ├── _app.tsx                        # sidebar + topbar + tab bar mobile
    ├── index.tsx                       # Dashboard (compteur rubis + KPIs)
    ├── factures.tsx                    # Liste filtrable
    ├── factures_.$id.tsx               # Détail facture + timeline relances
    ├── factures_.import.tsx            # Drag-and-drop OCR (étape 1)
    ├── factures_.import_.$batchId.tsx  # Vérification OCR (étape 2)
    ├── clients.tsx, clients_.$id.tsx
    ├── plans.tsx, plans_.$slug.tsx, plans_.nouveau.tsx
    ├── parametres.tsx, parametres_.abonnement.tsx
    ├── insights.tsx                    # KPIs avancés (timeseries)
    ├── admin.blog.tsx                  # liste posts (admin only)
    └── admin.blog_.$id.tsx             # éditeur post + publish workflow

Les routes en _app/* sont sous layout group route (pas de segment URL ajouté). Le suffixe _ dans factures_.$id empêche l'imbrication visuelle dans factures.tsx (TanStack Router file-based convention).

Configuration root

src/routes/__root.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 (
    <>
      <Outlet />
      <Toaster position="bottom-right" />
      {import.meta.env.DEV && (
        <>
          <TanStackRouterDevtools />
          <ReactQueryDevtools />
        </>
      )}
    </>
  )
}

function NotFound() {
  return <div className="p-8">Page introuvable.</div>
}

Auth guard sur le layout _app

src/routes/_app/_app.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 (
    <AppLayout>
      <Outlet />
    </AppLayout>
  )
}

Search params type-safe (filtres factures)

src/routes/_app/factures.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 :

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(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </StrictMode>
)

Convention queryKeys

Dans src/lib/queryKeys.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

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 — historique, non utilisé en V1

⚠️ Cette section décrit un choix initial qui n'a pas été retenu côté SPA livré. Le client HTTP réel est un fetch() minimaliste dans apps/web/src/lib/api.ts, et le partage de types entre l'API et le SPA passe par packages/shared (Zod schemas + types TS exportés en workspace). Section gardée pour mémoire et pour un éventuel pivot futur si la surface API grossit suffisamment.

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/)

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) :

// 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é :

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/)

cd apps/web
pnpm add @tuyau/client

src/lib/api.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.pro
  credentials: 'include',                    // envoie les cookies (refresh token)
  headers: () => ({
    Authorization: authStore.token ? `Bearer ${authStore.token}` : '',
  }),
})

Intégration avec TanStack Query

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 :

{
  "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 :

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) :

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 :

  • <Brand> — gem ◆ + wordmark "Rubis" / "Rubis Sur l'Ongle"
  • <Button variant="primary"|"secondary"|"ghost"> — cohérent avec la landing
  • <Input>, <Textarea>, <Select> — avec label + erreur
  • <Card>, <Panel> — surfaces standardisées
  • <Modal> — pour mise en demeure (validation manuelle obligatoire), saisie manuelle
  • <Chip> — filtres et tags de volume/tonalité
  • <RubisCounter> — composant héros gamification
  • <Stepper> — wizard onboarding 3 étapes
  • <Timeline> — pour le détail facture
  • <Dropzone> — drag & drop multi-fichiers PDF

9. Conventions

Structure de dossiers

apps/web/src/
├── routes/                # TanStack Router file-based
├── components/
│   ├── ui/                # Primitives (Button, Input, Card…)
│   ├── layout/            # AppLayout, OnboardingLayout
│   ├── factures/          # Composants spécifiques au domaine factures
│   ├── plans/             # Composants spécifiques aux plans
│   └── shared/            # Brand, RubisCounter, Stepper…
├── lib/
│   ├── api.ts             # Client Tuyau
│   ├── auth.ts            # Auth store
│   ├── queryKeys.ts       # Convention queryKeys
│   ├── format.ts          # Formateurs (€, dates, durées en rubis…)
│   └── utils.ts           # cn() helper, divers
├── hooks/
│   ├── useInvoices.ts     # Wrapper TanStack Query par domaine
│   ├── usePlans.ts
│   └── …
├── styles/
│   └── app.css            # Imports Tailwind + tokens
└── main.tsx               # Bootstrap + providers

Naming

  • Composants : PascalCase.tsx
  • Hooks : useCamelCase.ts
  • Helpers : camelCase.ts
  • Routes : kebab-case.tsx (TanStack Router file-based)
  • Tests : *.test.tsx colocalisé avec le composant

Style code

  • TypeScript strict: true, pas de any non justifié
  • Imports absolus via alias @/* mappé sur src/* (tsconfig.json + vite.config.ts)
  • Préférer destructuring + early returns plutôt qu'imbriquer
  • Composants fonctionnels uniquement, hooks pour la logique
  • Une responsabilité par composant — refactor en plusieurs fichiers dès qu'un composant dépasse ~200 lignes

Formateurs métier

Centraliser dans src/lib/format.ts les conversions récurrentes :

export const formatEuros = (cents: number) => /* "1 240,00 €" */
export const formatRubisToHours = (rubis: number) => /* "20 h 40" */
export const formatDate = (iso: string) => /* "5 mai 2026" */
export const formatRelativeDate = (iso: string) => /* "dans 3 jours" */

→ Cohérence de l'affichage partout, et on évite que chaque composant fasse son Intl.NumberFormat à la main.


10. Variables d'environnement

apps/web/.env.local (git-ignoré) :

VITE_API_URL=http://localhost:3333
VITE_PUBLIC_LANDING_URL=https://rubis.pro

Production : le SPA appelle son API via le même origin (/api/v1/*), proxifié par le nginx du pod rubis-web vers rubis-api:3333 côté K3s. Pas de hostname api.rubis.pro. Le VITE_API_URL peut donc être laissé vide ou '/api/v1' en prod.

VITE_API_URL=                              # vide → fetch en relatif
VITE_PUBLIC_LANDING_URL=https://rubis.pro
VITE_USE_MOCKS=false
VITE_SENTRY_DSN_WEB=<dsn>                  # ADR-024
VITE_APP_VERSION=$GIT_SHA                  # tag image / sha commit

Toutes les vars accessibles côté SPA doivent être préfixées VITE_ (sinon Vite ne les expose pas au bundle). Liste complète : voir apps/web/src/lib/env.ts.


11. Build & déploiement

Build local

pnpm -F web build      # produit apps/web/dist/

Image Docker

Dockerfile.web à la racine du monorepo :

# Stage 1 : build
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/web ./apps/web
COPY packages/shared ./packages/shared
RUN pnpm install --frozen-lockfile
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm -F web build

# Stage 2 : nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
# Réutilise le même nginx.conf que la landing (try_files / SPA)
COPY infra/nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

Ce Dockerfile sera buildé en parallèle du Dockerfile.api par le CI Gitea, puis poussé en git.arthurbarre.fr/ordinarthur/rubis-web:<sha>.


12. Pointeurs vers l'existant

Quand tu reconstruis l'app, rien ne se réinvente sans consulter :

  • Wireframes : /docs/wireframes-mvp.html — 13 écrans avec annotations UX par écran
  • Brand visuel : /docs/brand-identity.html — logo direction A, palette en application
  • Brand écrit : /docs/marque.md — règles do/don't, voix
  • Voix & microcopy : /docs/marque.md section 7 — messages de notif, empty states, erreurs
  • Landing déployée : /landing/index.html — référence vivante de couleurs/spacing/typo en HTML/Tailwind-compatible

→ Avant de styler un composant, regarde la landing déployée ou le wireframe correspondant. Cohérence visuelle = signature de marque tenue.


12-bis. Observability — Sentry (ADR-024)

Error monitoring + replay sur le SPA via @sentry/react. Init au plus tôt dans main.tsx AVANT tout autre import non-essentiel pour capturer les erreurs de bootstrap.

Configuration

// apps/web/src/lib/sentry.ts — init basé sur VITE_SENTRY_DSN_WEB
// (no-op si DSN absent → dev local sans bruit Sentry par défaut)
Aspect Valeur Rationale
Sample rate traces 10 % prod, 100 % dev Quota free tier (5K events/mois)
Sample rate replay session 0 % Pas de replay sans erreur — économie quota
Sample rate replay sur erreur 100 % Capture les 30 s précédant le crash
maskAllText / blockAllMedia true Privacy par défaut, on relâche après
release sha git court (CI build-arg) Une régression ↔ un commit
User context user.id UUID seulement Pas d'email/nom (PII minimisée)

Triggers automatiques (déjà câblés)

  • Erreurs runtime : window.onerror + unhandledrejection interceptés par le SDK.
  • Sentry.ErrorBoundary dans main.tsx autour de l'app → capture les erreurs React + affiche <FallbackError />.
  • User context : authStore.setSession appelle Sentry.setUser({ id }), authStore.clear reset.

Capturer manuellement une erreur métier

Dans un component / handler :

import * as Sentry from "@sentry/react"

try {
  await doSomethingRisky()
} catch (err) {
  Sentry.captureException(err, {
    tags: { feature: "ocr-import" },
    extra: { batchId },
  })
  toast.error("Quelque chose a coincé.")
}

Tester l'intégration en prod

⚠️ Les throw tapés dans la console DevTools ne hittent PAS window.onerror (ils restent dans le scope d'eval du devtools). Pour un test valide :

// Dans la console DevTools
setTimeout(() => { throw new Error("sentry test") }, 0)
// OU
Promise.reject(new Error("sentry test"))

Les deux méthodes traversent la vraie boucle d'événements de la page → Sentry intercepte. Vérifier ensuite Sentry Dashboard → projet rubis-web.

Pour vérifier que le SDK est bien init :

typeof __SENTRY__  // doit être "object" (sinon DSN absent au build)

Source maps

@sentry/vite-plugin est actif au build CI (gardé par SENTRY_AUTH_TOKEN). Il :

  1. Upload les .map à Sentry (désobfuscation des stack traces)
  2. Supprime les .map du dist/ final (filesToDeleteAfterUpload)

→ nginx en prod ne sert pas les sourcemaps publiquement. Sécurité critique.

Variables CI (secrets Gitea Actions)

Cf. .gitea/workflows/deploy-web.yml. Les 4 secrets requis :

  • SENTRY_DSN_WEB — DSN public projet rubis-web (bake-able dans le bundle)
  • SENTRY_AUTH_TOKEN — scope project:releases + project:write (upload sourcemaps)
  • SENTRY_ORG — slug org Sentry

Le build est tolérant : si SENTRY_AUTH_TOKEN manque, le plugin Vite est skip et les sourcemaps ne sont pas uploadées (Sentry capture quand même mais stack traces minifiées).


13. Points d'attention

  • Auth Bearer en mémoire : si l'utilisateur reload, l'access token est perdu — toujours appeler /auth/refresh au boot avant le render initial (cf. section 7)
  • Tuyau import path : le client SPA importe les types depuis apps/api/.adonisjs/api.ts — bien configurer les paths du tsconfig.json pour que ça fonctionne en monorepo
  • Polices : Bricolage Grotesque doit être préchargée (preconnect Google Fonts) sinon FOUT/FOIT visible sur les titres
  • Le ◆ : c'est un SVG inline, pas une icône Lucide. Composant <Gem /> à coder à part — réutilisé partout (sidebar, dashboard hero, badge mobile, CTA gamification)
  • Mobile-first sur les actions critiques : la photo de facture depuis le tel est un usage clé (cf. wireframe 4.3) — ne pas la traiter comme une feature secondaire
  • 3 clics maximum : règle de design (cf. ADR-011) — chaque parcours doit être contesté à l'aune de cette règle

14. Décisions encore à prendre côté frontend

Sujet Quand trancher
Lib de formulaires (TanStack Form vs react-hook-form vs natif) Avant le 1er formulaire complexe (signup ou plan editor)
State client local (Zustand vs context vs Jotai) Probablement pas nécessaire en V1 — TanStack Query gère 95 % du state
Composants accessibility primitives (Radix vs Headless UI vs natif) Avant le 1er Modal/Select complexe
Tests E2E (Playwright vs Cypress) Phase polish

Maintenu par Arthur + Claude. Les décisions structurelles passent par un ADR dans /docs/decisions.md.