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>
871 lines
28 KiB
Markdown
871 lines
28 KiB
Markdown
# 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`.*
|