Bascule du domaine principal vers rubis.pro / app.rubis.pro : - K3s ConfigMaps (api.yml, web.yml) : APP_URL, WEB_URL, COOKIE_DOMAIN, OAUTH callbacks pointent vers app.rubis.pro - Dockerfile.web : ARG VITE_API_URL et VITE_PUBLIC_LANDING_URL - Workflows Gitea : commentaires + build args web → rubis.pro - Code API (mail_dispatcher, send_test_email, config/mail) : defaults env LANDING_URL et MAIL_FROM_ADDRESS migrés - Templates env (.env.example) idem - Docs (architecture, backend, frontend, brand-identity) idem - AGENTS.md / CLAUDE.md / deploy-memory : pointeurs domaine MAJ Note : MAIL_FROM_ADDRESS dans le secret K3s reste sur rubis@arthurbarre.fr tant que le domaine rubis.pro n'est pas Verified dans Resend. À switcher manuellement après vérif Resend. Compat : un 301 Traefik redirige rubis.arthurbarre.fr → rubis.pro (et app.X aussi) — config Ansible dans le repo proxmox. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
764 lines
23 KiB
Markdown
764 lines
23 KiB
Markdown
# Guide d'implémentation — Frontend
|
|
|
|
> Version : 0.1 · Dernière maj : 2026-05-05
|
|
> Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-017 (auth).
|
|
|
|
Ce document est le **guide pratique d'implémentation du SPA**. Il complète `architecture.md` (qui décrit le **quoi**) en expliquant le **comment** : commandes exactes, snippets de config, conventions de dossier.
|
|
|
|
**À lire avant** :
|
|
- `/CLAUDE.md` — contexte top-level
|
|
- `/docs/produit.md` — flows utilisateur, IN/OUT V1
|
|
- `/docs/marque.md` — palette, typo, voix, do/don't
|
|
- `/docs/wireframes-mvp.html` — les 13 écrans MVP avec annotations
|
|
- `/docs/tech/architecture.md` — vue d'ensemble du système
|
|
|
|
---
|
|
|
|
## 1. Vue d'ensemble
|
|
|
|
L'app web (`apps/web/`) est un SPA React 19 buildé par Vite, qui consomme l'API AdonisJS `apps/api/` via un client HTTP type-safe (Tuyau). Le routing client est géré par **TanStack Router** (file-based, type-safe), le state serveur par **TanStack Query**, le styling par **Tailwind CSS v4** avec les tokens de marque issus de `marque.md`.
|
|
|
|
**Périmètre V1** : 13 écrans listés dans `wireframes-mvp.html`. Auth Bearer (cf. ADR-017) avec refresh token httpOnly cookie. Mobile responsive, pas d'app native.
|
|
|
|
**Hors scope V1** : SSR (pas nécessaire pour un SaaS B2B authentifié), i18n (FR uniquement), PWA offline (nice-to-have V2).
|
|
|
|
---
|
|
|
|
## 2. Dépendances
|
|
|
|
### Bootstrap du workspace
|
|
|
|
À exécuter à la racine du monorepo (après avoir créé `pnpm-workspace.yaml`) :
|
|
|
|
```bash
|
|
mkdir -p apps/web && cd apps/web
|
|
pnpm create vite@latest . --template react-ts
|
|
```
|
|
|
|
Choix Vite : `react-ts` (TypeScript natif).
|
|
|
|
### Dépendances runtime
|
|
|
|
```bash
|
|
# Routing & state serveur
|
|
pnpm add @tanstack/react-router @tanstack/react-query @tanstack/react-query-devtools
|
|
|
|
# Tooling Vite pour TanStack Router (file-based)
|
|
pnpm add -D @tanstack/router-plugin
|
|
|
|
# Client HTTP typé pour AdonisJS
|
|
pnpm add @tuyau/client
|
|
|
|
# UI primitives & icônes
|
|
pnpm add lucide-react
|
|
pnpm add clsx tailwind-merge
|
|
|
|
# Validation côté client (réutilise schemas Zod de packages/shared)
|
|
pnpm add zod
|
|
|
|
# Notifications/toasts
|
|
pnpm add sonner
|
|
|
|
# Dates (formatage français)
|
|
pnpm add date-fns
|
|
```
|
|
|
|
### Dépendances dev
|
|
|
|
```bash
|
|
# Tailwind v4
|
|
pnpm add -D tailwindcss @tailwindcss/vite
|
|
|
|
# TS strict + lint
|
|
pnpm add -D typescript@latest @types/react @types/react-dom
|
|
pnpm add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
|
|
pnpm add -D eslint-plugin-react-hooks eslint-plugin-react-refresh
|
|
|
|
# Tests
|
|
pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom
|
|
|
|
# Type-checking strict
|
|
pnpm add -D tsc-files
|
|
```
|
|
|
|
### Référence au package shared
|
|
|
|
Dans `apps/web/package.json` :
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"@rubis/shared": "workspace:*"
|
|
}
|
|
}
|
|
```
|
|
|
|
Permet d'importer les types et schemas Zod depuis `packages/shared/` sans publication npm.
|
|
|
|
---
|
|
|
|
## 3. Tailwind CSS v4 + tokens de marque
|
|
|
|
Tailwind v4 utilise une configuration CSS-first (plus de `tailwind.config.js` requis). Les tokens de marque issus de `marque.md` deviennent des CSS variables.
|
|
|
|
### Installation
|
|
|
|
`vite.config.ts` :
|
|
|
|
```ts
|
|
import { defineConfig } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import tailwindcss from '@tailwindcss/vite'
|
|
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
|
|
|
export default defineConfig({
|
|
plugins: [
|
|
TanStackRouterVite({ routesDirectory: 'src/routes' }),
|
|
react(),
|
|
tailwindcss(),
|
|
],
|
|
})
|
|
```
|
|
|
|
### Tokens de marque dans `src/styles/app.css`
|
|
|
|
```css
|
|
@import "tailwindcss";
|
|
|
|
@theme {
|
|
/* Couleurs rubis */
|
|
--color-rubis: #9F1239;
|
|
--color-rubis-deep: #771328;
|
|
--color-rubis-light: #C9415C;
|
|
--color-rubis-glow: #FBE4EA;
|
|
|
|
/* Neutres chauds */
|
|
--color-cream: #FAF7F2;
|
|
--color-cream-2: #F5EFE7;
|
|
--color-line: #E8E0D6;
|
|
--color-ink: #1A1410;
|
|
--color-ink-2: #4F4640;
|
|
--color-ink-3: #8A7F76;
|
|
|
|
/* Typographies */
|
|
--font-display: "Bricolage Grotesque", -apple-system, BlinkMacSystemFont, sans-serif;
|
|
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
|
}
|
|
|
|
/* Globals */
|
|
body {
|
|
font-family: var(--font-sans);
|
|
background: var(--color-cream);
|
|
color: var(--color-ink);
|
|
font-feature-settings: "ss01", "cv11";
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
/* Selection */
|
|
::selection {
|
|
background: var(--color-rubis);
|
|
color: white;
|
|
}
|
|
```
|
|
|
|
Importé dans `main.tsx` : `import './styles/app.css'`.
|
|
|
|
### Polices Google Fonts
|
|
|
|
`index.html` :
|
|
|
|
```html
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
```
|
|
|
|
### 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 # 1.2 Connexion
|
|
├── signup.tsx # 1.1 Inscription
|
|
├── _onboarding/ # Layout onboarding (sans sidebar)
|
|
│ ├── _onboarding.tsx
|
|
│ ├── compte.tsx # 1.3 step 1
|
|
│ ├── entreprise.tsx # 1.3 step 2
|
|
│ └── signature.tsx # 1.3 step 3
|
|
└── _app/ # Layout app authentifiée
|
|
├── _app.tsx # Layout : sidebar + topbar + tab bar mobile
|
|
├── index.tsx # 4.1 Dashboard
|
|
├── factures.tsx # 2.4 Liste filtrable
|
|
├── factures.$id.tsx # 4.2 Détail facture (timeline)
|
|
├── factures.import.$batchId.tsx # 2.2 Vérification OCR
|
|
├── plans.tsx # 3.1 Bibliothèque
|
|
├── plans.$slug.tsx # 3.2 Éditeur (cadence + templates)
|
|
├── clients.tsx # liste clients
|
|
└── parametres.tsx # paramètres compte
|
|
```
|
|
|
|
Les routes commençant par `_` sont des **layout routes** (n'ajoutent pas de segment URL).
|
|
|
|
### Configuration root
|
|
|
|
`src/routes/__root.tsx` :
|
|
|
|
```tsx
|
|
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|
import { Toaster } from 'sonner'
|
|
|
|
export const Route = createRootRoute({
|
|
component: RootLayout,
|
|
notFoundComponent: NotFound,
|
|
})
|
|
|
|
function RootLayout() {
|
|
return (
|
|
<>
|
|
<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 — client HTTP typé pour AdonisJS
|
|
|
|
[Tuyau](https://github.com/Julien-R44/tuyau) est l'équivalent tRPC pour AdonisJS. Il génère un client TS qui connaît toutes les routes API, leurs payloads, et leurs réponses — depuis le code Adonis lui-même.
|
|
|
|
### Côté API (à faire dans `apps/api/`)
|
|
|
|
```bash
|
|
cd apps/api
|
|
pnpm add @tuyau/core
|
|
node ace add @tuyau/core
|
|
```
|
|
|
|
Configuration : annoter les routes Adonis avec un nom (utilisé pour l'autocomplete TS) :
|
|
|
|
```ts
|
|
// apps/api/start/routes.ts
|
|
router.group(() => {
|
|
router.post('/auth/login', '#controllers/auth_controller.login').as('auth.login')
|
|
router.get('/me', '#controllers/me_controller.show').as('me.show').use(middleware.auth())
|
|
|
|
router.get('/invoices', '#controllers/invoices_controller.index')
|
|
.as('invoices.index').use(middleware.auth())
|
|
router.post('/invoices', '#controllers/invoices_controller.store')
|
|
.as('invoices.store').use(middleware.auth())
|
|
router.get('/invoices/:id', '#controllers/invoices_controller.show')
|
|
.as('invoices.show').use(middleware.auth())
|
|
// ...
|
|
}).prefix('/api/v1')
|
|
```
|
|
|
|
Génération du client typé :
|
|
|
|
```bash
|
|
node ace tuyau:generate
|
|
```
|
|
|
|
Crée `.adonisjs/api.ts` qui décrit toutes les routes en types TS. Ce fichier est versionné OU regénéré au build (à choisir).
|
|
|
|
### Côté SPA (`apps/web/`)
|
|
|
|
```bash
|
|
cd apps/web
|
|
pnpm add @tuyau/client
|
|
```
|
|
|
|
`src/lib/api.ts` :
|
|
|
|
```ts
|
|
import { createTuyau } from '@tuyau/client'
|
|
import { api } from '../../../api/.adonisjs/api' // import des types depuis l'API
|
|
|
|
export const tuyau = createTuyau({
|
|
api,
|
|
baseUrl: import.meta.env.VITE_API_URL, // ex. https://api.rubis.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 via secret K3s injecté dans le build Vite :
|
|
|
|
```bash
|
|
VITE_API_URL=https://api.rubis.pro
|
|
VITE_PUBLIC_LANDING_URL=https://rubis.pro
|
|
```
|
|
|
|
Toutes les vars accessibles côté SPA **doivent être préfixées `VITE_`** (sinon Vite ne les expose pas au bundle).
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
---
|
|
|
|
## 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`.*
|