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

```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 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`).

```bash
pnpm add @fontsource-variable/bricolage-grotesque @fontsource-variable/inter
```

```css
/* 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

```tsx
<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` :

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

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

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

### 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 — *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](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.pro
  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** :

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

```ts
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é) :

```bash
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.

```bash
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

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

### Image Docker

`Dockerfile.web` à la racine du monorepo :

```dockerfile
# 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

```ts
// 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 :

```ts
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 :

```js
// 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 :
```js
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`.*
