diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml
index c3ee9c0..c664077 100644
--- a/.gitea/workflows/deploy-web.yml
+++ b/.gitea/workflows/deploy-web.yml
@@ -8,6 +8,7 @@ on:
paths:
- 'apps/web/**'
- 'packages/shared/**'
+ - 'packages/ui/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'package.json'
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index edd74e7..6d04077 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -1,21 +1,24 @@
name: Build & Deploy Landing
-# Workflow pour la landing static (rubis.pro).
-# L'app SaaS (apps/api + apps/web) a son propre workflow : deploy-app.yml.
+# Workflow pour la landing Astro (rubis.pro). Migration depuis l'ancienne
+# landing nginx statique : rendu SSR par Node 22 (Astro adapter standalone),
+# pages statiques prerenderées + blog en SSR pur fetch via apps/api.
on:
push:
branches: [main]
paths:
- - 'landing/**'
- - 'Dockerfile'
+ - 'apps/landing/**'
+ - 'packages/ui/**'
+ - 'packages/shared/**'
+ - 'Dockerfile.landing'
- 'k3s/namespace.yml'
- - 'k3s/deployment.yml'
- - 'k3s/service.yml'
+ - 'k3s/app/landing.yml'
- '.gitea/workflows/deploy.yml'
+ - 'pnpm-lock.yaml'
env:
REGISTRY: git.arthurbarre.fr
- IMAGE: ordinarthur/rubis
+ IMAGE: ordinarthur/rubis-landing
NAMESPACE: rubis
jobs:
@@ -36,7 +39,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
- file: Dockerfile
+ file: Dockerfile.landing
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
@@ -62,10 +65,9 @@ jobs:
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f k3s/deployment.yml
- kubectl apply -f k3s/service.yml
+ kubectl apply -f k3s/app/landing.yml
- kubectl -n $NAMESPACE set image deployment/rubis \
- rubis=$REGISTRY/$IMAGE:${{ github.sha }}
+ kubectl -n $NAMESPACE set image deployment/rubis-landing \
+ landing=$REGISTRY/$IMAGE:${{ github.sha }}
- kubectl -n $NAMESPACE rollout status deployment/rubis --timeout=120s
+ kubectl -n $NAMESPACE rollout status deployment/rubis-landing --timeout=180s
diff --git a/CLAUDE.md b/CLAUDE.md
index 830b1af..f8a02ed 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -75,6 +75,7 @@ Direct, concret, chaleureux, précis, empathique. *On parle comme un bon associ
- Liste filtrable des factures
- Détail facture avec timeline des relances
- App mobile (web responsive)
+- **Blog `rubis.pro/blog`** — SSR par `apps/landing` (Astro 6), contenu en DB (`posts`) servi par `apps/api` via `/api/v1/posts/*`, admin de validation côté `app.rubis.pro/admin/blog` (à venir PR3), génération hebdomadaire IA via cron (Sonnet 4.6) avec review humaine obligatoire. Détails dans `/docs/tech/architecture.md`.
### OUT (V2 ou plus tard)
@@ -111,16 +112,16 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
| Couche | Choix | Source |
|---|---|---|
-| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
-| Frontend | **React + Vite** | ADR-014 |
-| Routing client | **TanStack Router** | ADR-014 |
-| State serveur | **TanStack Query** | ADR-014 |
+| Backend (API) | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
+| App SPA (`app.rubis.pro`) | **React 19 + Vite + TanStack Router/Query** | ADR-014 |
+| Landing + blog (`rubis.pro`) | **Astro 6 SSR** (pages statiques prerenderées + blog en SSR) | — |
+| Design system | **`@rubis/ui`** — Tailwind v4 tokens + composants TSX | — |
| Base de données | **PostgreSQL** | ADR-014 |
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
| OCR provider | à benchmarker | ADR-020 (en attente) |
| Email outbound | à benchmarker | ADR-021 (en attente) |
-**Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
+**Architecture** : monorepo Turborepo (`apps/api` AdonisJS, `apps/web` React SaaS, `apps/landing` Astro public, `packages/shared` types/schemas, `packages/ui` design system). API REST Bearer-auth, deux frontends qui consomment `@rubis/ui` pour un brand visuel unifié, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
@@ -133,10 +134,10 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
| Fichier | Rôle |
|---|---|
| `/CLAUDE.md` (ce fichier) | Contexte top-level, toujours en tête |
-| `/landing/index.html` | Landing page brand-applied, déployée (waitlist V1) |
-| `/landing/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon |
-| `/landing/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
-| `/landing/assets/logo.png` | Logo Rubis original (généré, source pour les favicons) |
+| `/apps/landing/` | Landing publique + blog (Astro 6 SSR) — déployée sur `rubis.pro` |
+| `/apps/landing/public/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon |
+| `/apps/landing/public/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
+| `/packages/ui/` | Design system partagé (tokens Tailwind v4 + composants TSX) |
| `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing) |
| `/docs/flow.md` | **Comportement produit deep-dive** : cycle de vie d'une facture, statuts + transitions, surfaces UI, mécanique de confirmation (check-in), mode démo, edge cases |
| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) |
@@ -153,9 +154,10 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
## Déploiement
-- **Domaine principal** : https://rubis.pro (landing) + https://app.rubis.pro (SaaS V1)
-- **Image landing** : `git.arthurbarre.fr/ordinarthur/rubis:latest`
-- **Build landing** : `COPY landing/` → nginx servi sur port 80
+- **Domaine principal** : https://rubis.pro (landing + blog Astro) + https://app.rubis.pro (SaaS React)
+- **Image landing** : `git.arthurbarre.fr/ordinarthur/rubis-landing:latest` (Astro Node SSR, port 4321)
+- **Image API** : `git.arthurbarre.fr/ordinarthur/rubis-api:latest` (port 3333)
+- **Image SPA** : `git.arthurbarre.fr/ordinarthur/rubis-web:latest` (nginx + proxy /api → rubis-api)
- **Compat** : `rubis.arthurbarre.fr` / `app.rubis.arthurbarre.fr` redirigent en 301 vers `rubis.pro` / `app.rubis.pro` (config Traefik dans repo proxmox)
- Voir `.claude/deploy-memory.md` pour la procédure complète.
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index e883a01..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-FROM nginx:1.27-alpine
-
-COPY landing/ /usr/share/nginx/html/
-
-RUN printf 'server {\n\
- listen 80;\n\
- server_name _;\n\
- root /usr/share/nginx/html;\n\
- index index.html;\n\
-\n\
- location / {\n\
- try_files $uri $uri/ /index.html;\n\
- }\n\
-\n\
- location ~* \\.(?:css|js|svg|png|jpg|jpeg|gif|ico|webp|woff2?)$ {\n\
- expires 7d;\n\
- add_header Cache-Control "public, max-age=604800, immutable";\n\
- }\n\
-\n\
- location = /site.webmanifest {\n\
- add_header Content-Type application/manifest+json;\n\
- }\n\
-}\n' > /etc/nginx/conf.d/default.conf
-
-EXPOSE 80
diff --git a/Dockerfile.landing b/Dockerfile.landing
new file mode 100644
index 0000000..6e50c17
--- /dev/null
+++ b/Dockerfile.landing
@@ -0,0 +1,71 @@
+# syntax=docker/dockerfile:1.7
+# =============================================================================
+# Rubis — image landing (Astro 6, React 19, Node SSR standalone)
+# Sert rubis.pro (landing + pages légales + blog SSR fetch via apps/api).
+# =============================================================================
+#
+# Astro adapter: @astrojs/node en mode "standalone" → bundle un mini-server
+# Node dans dist/server/entry.mjs. Pas de nginx en frontal nécessaire au
+# niveau du pod : Traefik (cluster) gère le TLS et le routing par hostname.
+# =============================================================================
+
+ARG NODE_VERSION=22.13.1
+ARG PNPM_VERSION=10.0.0
+
+# -----------------------------------------------------------------------------
+# base — node + pnpm + tini
+# -----------------------------------------------------------------------------
+FROM node:${NODE_VERSION}-alpine AS base
+ARG PNPM_VERSION
+RUN apk add --no-cache libc6-compat tini && \
+ corepack enable && \
+ corepack prepare pnpm@${PNPM_VERSION} --activate
+WORKDIR /repo
+
+# -----------------------------------------------------------------------------
+# deps — install workspace (avec devDeps pour le build Astro)
+# -----------------------------------------------------------------------------
+FROM base AS deps
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./
+COPY apps/landing/package.json ./apps/landing/
+COPY packages/shared/package.json ./packages/shared/
+COPY packages/ui/package.json ./packages/ui/
+RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
+ pnpm install --frozen-lockfile --filter @rubis/landing...
+
+# -----------------------------------------------------------------------------
+# build — astro build (output: server, adapter Node standalone)
+# -----------------------------------------------------------------------------
+FROM deps AS build
+COPY packages/shared ./packages/shared
+COPY packages/ui ./packages/ui
+COPY apps/landing ./apps/landing
+RUN cd apps/landing && pnpm exec astro build
+
+# -----------------------------------------------------------------------------
+# runner — runtime minimal, user non-root
+# -----------------------------------------------------------------------------
+FROM base AS runner
+RUN addgroup -g 1001 -S nodejs && adduser -S astro -u 1001
+
+ENV NODE_ENV=production \
+ HOST=0.0.0.0 \
+ PORT=4321 \
+ LOG_LEVEL=info
+
+WORKDIR /app
+
+# Astro standalone bundle suffit (server + client static + node_modules
+# nécessaires à l'entry sont déjà inclus dans dist/).
+COPY --from=build --chown=astro:nodejs /repo/apps/landing/dist /app/dist
+
+USER astro
+
+EXPOSE 4321
+
+# Healthcheck simple : / répond 200 (page d'accueil prerenderée).
+HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
+ CMD wget -qO- http://127.0.0.1:4321/ >/dev/null 2>&1 || exit 1
+
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["node", "/app/dist/server/entry.mjs"]
diff --git a/Dockerfile.web b/Dockerfile.web
index 8567498..1b42369 100644
--- a/Dockerfile.web
+++ b/Dockerfile.web
@@ -42,6 +42,7 @@ WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
+COPY packages/ui/package.json ./packages/ui/
# Install : on prend tout le workspace pour que les workspace deps résolvent.
# Le filter --include-deps évite de gaspiller en installant les deps de l'API.
@@ -49,6 +50,7 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY packages/shared ./packages/shared
+COPY packages/ui ./packages/ui
COPY apps/web ./apps/web
# Re-déclare les ARG dans le stage où on les utilise (Docker scope).
diff --git a/apps/api/app/controllers/blog_controller.ts b/apps/api/app/controllers/blog_controller.ts
new file mode 100644
index 0000000..3a90c10
--- /dev/null
+++ b/apps/api/app/controllers/blog_controller.ts
@@ -0,0 +1,60 @@
+import type { HttpContext } from '@adonisjs/core/http'
+
+import Post from '#models/post'
+import PostTransformer, { PostSummaryTransformer } from '#transformers/post_transformer'
+
+/**
+ * BlogController — endpoints JSON publics pour le blog.
+ *
+ * L'API ne sert plus de HTML : le rendu des pages blog est délégué à
+ * apps/landing (Astro SSR). Ce contrôleur expose uniquement les données.
+ *
+ * Routes (cf. start/routes.ts, sous /api/v1/posts) :
+ * GET /api/v1/posts → liste des articles publiés (summary)
+ * GET /api/v1/posts/:slug → article complet + articles liés
+ *
+ * Pas d'auth : le blog est public. Pas de pagination V1 (volume <100 articles
+ * sur les 12 prochains mois — pas de besoin). On la rajoutera si nécessaire.
+ *
+ * Convention sérialisation : on passe des plain objects à `response.json` pour
+ * matcher le pattern utilisé par clients_controller etc. (les autres contrôleurs
+ * du codebase n'utilisent pas `serialize()` pour les arrays).
+ */
+export default class BlogController {
+ /**
+ * GET /api/v1/posts — liste publique, articles publiés du plus récent au
+ * plus ancien, sans le contentHtml (payload léger pour la page liste).
+ */
+ async index({ response }: HttpContext) {
+ const posts = await Post.query().withScopes((s) => s.published())
+ return response.json({
+ data: posts.map((p) => new PostSummaryTransformer(p).toObject()),
+ })
+ }
+
+ /**
+ * GET /api/v1/posts/:slug — article publié + 3 articles liés (intersection
+ * de tags). 404 si non trouvé ou pas publié.
+ */
+ async show({ params, response }: HttpContext) {
+ const slug = String(params.slug ?? '')
+ const post = await Post.query()
+ .where('slug', slug)
+ .where('status', 'published')
+ .whereNotNull('publishedAt')
+ .first()
+
+ if (!post) {
+ return response.status(404).json({ error: 'post_not_found' })
+ }
+
+ const related = await Post.query().withScopes((s) => s.relatedTo(post)).limit(3)
+
+ return response.json({
+ data: {
+ post: new PostTransformer(post).toObject(),
+ related: related.map((p) => new PostSummaryTransformer(p).toObject()),
+ },
+ })
+ }
+}
diff --git a/apps/api/app/models/post.ts b/apps/api/app/models/post.ts
new file mode 100644
index 0000000..74287fd
--- /dev/null
+++ b/apps/api/app/models/post.ts
@@ -0,0 +1,38 @@
+import { PostSchema } from '#database/schema'
+import { column, scope } from '@adonisjs/lucid/orm'
+
+export type PostStatus = 'draft' | 'published'
+
+export default class Post extends PostSchema {
+ // Override : le générateur infère `any` pour les enums + arrays Postgres,
+ // on retype proprement.
+ @column()
+ declare status: PostStatus
+
+ @column()
+ declare tags: string[]
+
+ /**
+ * Articles publiés, du plus récent au plus ancien.
+ * Utilisé par toutes les surfaces publiques (index, RSS, sitemap, related).
+ */
+ static published = scope((query) => {
+ query.where('status', 'published').whereNotNull('publishedAt').orderBy('publishedAt', 'desc')
+ })
+
+ /**
+ * Articles "liés" : intersection de tags non vide, hors article courant,
+ * triés par récence. Limit côté appelant.
+ */
+ static relatedTo = scope((query, post: Post) => {
+ if (post.tags.length === 0) {
+ query.whereRaw('1 = 0')
+ return
+ }
+ query
+ .where('status', 'published')
+ .whereNot('id', post.id)
+ .whereRaw('tags && ?::text[]', [post.tags])
+ .orderBy('publishedAt', 'desc')
+ })
+}
diff --git a/apps/api/app/services/blog_renderer.ts b/apps/api/app/services/blog_renderer.ts
new file mode 100644
index 0000000..ee0ff8f
--- /dev/null
+++ b/apps/api/app/services/blog_renderer.ts
@@ -0,0 +1,103 @@
+/**
+ * blog_renderer — pipeline markdown → HTML pour les articles du blog.
+ *
+ * Appelé :
+ * - au seeder (3 articles fondateurs en DB)
+ * - depuis l'admin React au save (PR3) via un endpoint dédié
+ * - depuis le cron weekly_blog_generator (PR4) pour les drafts IA
+ *
+ * Le HTML rendu est cache dans `posts.content_html` pour éviter de re-parser
+ * le markdown à chaque hit page. Si tu changes ce module, prévois une
+ * migration de re-render des posts existants.
+ */
+
+import { Marked, type Tokens } from 'marked'
+
+/** Mots/min retenus pour le calcul reading_time (moyenne lecteur fr web). */
+const WORDS_PER_MINUTE = 220
+
+/**
+ * Renderer marked configuré pour le blog Rubis :
+ * - GFM (tables, autolinks, ~strikethrough~)
+ * - heading IDs auto pour ancres / future TOC
+ * - liens externes en target=_blank rel=noopener
+ * - br: false (un saut de ligne ne devient pas , seul un \n\n crée un
)
+ */
+const marked = new Marked({
+ gfm: true,
+ breaks: false,
+ pedantic: false,
+})
+
+marked.use({
+ renderer: {
+ heading({ tokens, depth }: Tokens.Heading): string {
+ const text = this.parser.parseInline(tokens)
+ const id = slugify(stripTags(text))
+ return `${text}\n`
+ },
+ link({ href, title, tokens }: Tokens.Link): string {
+ const text = this.parser.parseInline(tokens)
+ const isExternal = /^https?:\/\//.test(href) && !href.startsWith('https://rubis.pro')
+ const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''
+ const relAttr = isExternal ? ' rel="noopener noreferrer"' : ''
+ const targetAttr = isExternal ? ' target="_blank"' : ''
+ return `${text}`
+ },
+ image({ href, title, text }: Tokens.Image): string {
+ // Lazy par défaut, dimensions à enrichir via image processing futur.
+ const altAttr = `alt="${escapeHtmlAttr(text || '')}"`
+ const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''
+ return ``
+ },
+ },
+})
+
+export type RenderedPost = {
+ contentHtml: string
+ wordCount: number
+ readingTimeMinutes: number
+}
+
+/** Markdown → HTML + métriques de lecture. */
+export function renderPost(contentMd: string): RenderedPost {
+ const contentHtml = marked.parse(contentMd, { async: false }) as string
+ const wordCount = countWords(contentMd)
+ const readingTimeMinutes = Math.max(1, Math.round(wordCount / WORDS_PER_MINUTE))
+ return { contentHtml, wordCount, readingTimeMinutes }
+}
+
+/**
+ * Slug ASCII kebab-case déterministe.
+ * "Comment relancer un client — sans rien casser" → "comment-relancer-un-client-sans-rien-casser"
+ */
+export function slugify(input: string): string {
+ return input
+ .normalize('NFD')
+ .replace(/[̀-ͯ]/g, '') // diacritiques
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 200)
+}
+
+function countWords(md: string): number {
+ // On strippe code blocks + balisage MD avant de compter — sinon les ``` et **
+ // gonflent artificiellement.
+ const text = md
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/`[^`]*`/g, ' ')
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/[*_~#>]/g, ' ')
+ const matches = text.match(/\S+/g)
+ return matches ? matches.length : 0
+}
+
+function escapeHtmlAttr(s: string): string {
+ return s.replace(/"/g, '"').replace(//g, '>')
+}
+
+function stripTags(s: string): string {
+ return s.replace(/<[^>]+>/g, '')
+}
diff --git a/apps/api/app/transformers/post_transformer.ts b/apps/api/app/transformers/post_transformer.ts
new file mode 100644
index 0000000..72f2a90
--- /dev/null
+++ b/apps/api/app/transformers/post_transformer.ts
@@ -0,0 +1,53 @@
+import type Post from '#models/post'
+import { BaseTransformer } from '@adonisjs/core/transformers'
+
+/**
+ * PostTransformer — sérialise un Post en JSON public pour l'app Astro qui
+ * rend les pages blog en SSR (cf. apps/landing/src/pages/blog/*.astro).
+ *
+ * On ne retourne JAMAIS contentMd (c'est la source d'édition) — uniquement
+ * contentHtml déjà rendu par blog_renderer côté API. Garde le payload léger
+ * et évite de re-parser markdown côté front.
+ */
+export default class PostTransformer extends BaseTransformer {
+ toObject() {
+ const p = this.resource
+ return {
+ id: p.id,
+ slug: p.slug,
+ title: p.title,
+ excerpt: p.excerpt,
+ contentHtml: p.contentHtml,
+ heroImageUrl: p.heroImageUrl,
+ heroImageAlt: p.heroImageAlt,
+ ogImageUrl: p.ogImageUrl,
+ canonicalUrl: p.canonicalUrl,
+ authorName: p.authorName,
+ tags: p.tags,
+ publishedAt: p.publishedAt?.toISO() ?? null,
+ updatedAt: p.updatedAt?.toISO() ?? null,
+ readingTimeMinutes: p.readingTimeMinutes,
+ wordCount: p.wordCount,
+ noindex: p.noindex,
+ }
+ }
+}
+
+/** Variante minimaliste pour les listes / articles liés (pas de contentHtml). */
+export class PostSummaryTransformer extends BaseTransformer {
+ toObject() {
+ const p = this.resource
+ return {
+ id: p.id,
+ slug: p.slug,
+ title: p.title,
+ excerpt: p.excerpt,
+ heroImageUrl: p.heroImageUrl,
+ heroImageAlt: p.heroImageAlt,
+ authorName: p.authorName,
+ tags: p.tags,
+ publishedAt: p.publishedAt?.toISO() ?? null,
+ readingTimeMinutes: p.readingTimeMinutes,
+ }
+ }
+}
diff --git a/apps/api/commands/seed_blog.ts b/apps/api/commands/seed_blog.ts
new file mode 100644
index 0000000..721b36d
--- /dev/null
+++ b/apps/api/commands/seed_blog.ts
@@ -0,0 +1,77 @@
+import { BaseCommand, flags } from '@adonisjs/core/ace'
+import type { CommandOptions } from '@adonisjs/core/types/ace'
+import { DateTime } from 'luxon'
+
+import Post from '#models/post'
+import { renderPost } from '#services/blog_renderer'
+import { seedArticles } from '#database/seeders/blog_seed/index'
+
+/**
+ * Insère / met à jour les articles fondateurs du blog en DB.
+ *
+ * node ace seed:blog # idempotent, upsert par slug
+ * node ace seed:blog --reset # supprime tous les posts avant
+ *
+ * À lancer une fois en local et en prod après le déploiement de PR1
+ * (avant la mise en route du routing Traefik en PR2). Les ré-exécutions
+ * sont sans effet de bord — utile si on retouche les MD source.
+ */
+export default class SeedBlog extends BaseCommand {
+ static commandName = 'seed:blog'
+ static description = 'Seed des 3 articles fondateurs du blog (idempotent par slug)'
+
+ static options: CommandOptions = {
+ startApp: true,
+ }
+
+ @flags.boolean({
+ description: 'Supprime tous les posts existants avant le seed',
+ default: false,
+ })
+ declare reset: boolean
+
+ async run() {
+ if (this.reset) {
+ const deleted = await Post.query().delete()
+ this.logger.warning(`${deleted} posts supprimés (--reset).`)
+ }
+
+ let created = 0
+ let updated = 0
+
+ for (const draft of seedArticles) {
+ const { contentHtml, wordCount, readingTimeMinutes } = renderPost(draft.contentMd)
+ const publishedAt = DateTime.now().minus({ days: draft.publishedDaysAgo }).toUTC().startOf('minute')
+
+ const existing = await Post.findBy('slug', draft.slug)
+ const payload = {
+ slug: draft.slug,
+ title: draft.title,
+ excerpt: draft.excerpt,
+ contentMd: draft.contentMd,
+ contentHtml,
+ authorName: draft.authorName,
+ tags: draft.tags,
+ status: 'published' as const,
+ publishedAt,
+ wordCount,
+ readingTimeMinutes,
+ aiGenerated: false,
+ noindex: false,
+ }
+
+ if (existing) {
+ existing.merge(payload)
+ await existing.save()
+ updated += 1
+ } else {
+ await Post.create(payload)
+ created += 1
+ }
+
+ this.logger.success(`✓ ${draft.slug} (${wordCount} mots, ${readingTimeMinutes} min)`)
+ }
+
+ this.logger.info(`\nFait : ${created} créé(s), ${updated} mis à jour, ${seedArticles.length} total.`)
+ }
+}
diff --git a/apps/api/database/migrations/1778250000000_create_posts_table.ts b/apps/api/database/migrations/1778250000000_create_posts_table.ts
new file mode 100644
index 0000000..35e421f
--- /dev/null
+++ b/apps/api/database/migrations/1778250000000_create_posts_table.ts
@@ -0,0 +1,89 @@
+import { BaseSchema } from '@adonisjs/lucid/schema'
+
+/**
+ * posts — articles du blog rubis.pro/blog
+ *
+ * SSR par AdonisJS (cf. apps/api/app/controllers/blog_controller.ts), routé
+ * via Traefik Host(`rubis.pro`) && PathPrefix(`/blog`) → service rubis-api.
+ * Le même pod Adonis sert /api/v1/* (host app.rubis.pro) et /blog/* (host
+ * rubis.pro), distingués au niveau du routeur Adonis.
+ *
+ * Champs SEO :
+ * - slug : segment d'URL stable, unique
+ * - title : + og:title + JSON-LD headline (≤60 chars validé côté admin)
+ * - excerpt : meta description + og:description + card preview (≤160 chars)
+ * - content_md : markdown source (édité dans l'admin)
+ * - content_html : rendu cache au save pour éviter le coût marked à chaque hit
+ * - hero_image_url : visuel d'en-tête, sert aussi de og:image par défaut
+ * - og_image_url : OG dédié si on veut un crop différent (sinon = hero)
+ * - canonical_url : pour cross-posting éventuel (sinon défaut = rubis.pro/blog/{slug})
+ * - tags : pour articles liés (recherche par tag commun) + filtres future
+ * - published_at : timestamp de publication (NULL tant que draft)
+ * - reading_time_minutes / word_count : calculés au save, affichés dans l'UI
+ * - ai_generated : flag pour distinguer les drafts générés par le cron weekly
+ * - noindex : permet de cacher un article aux crawlers (legal/test)
+ */
+export default class extends BaseSchema {
+ protected tableName = 'posts'
+
+ async up() {
+ this.schema.createTable(this.tableName, (table) => {
+ table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
+
+ // Clés SEO
+ table.string('slug', 200).notNullable().unique()
+ table.string('title', 200).notNullable()
+ table.string('excerpt', 280).notNullable()
+
+ // Contenu
+ table.text('content_md').notNullable()
+ table.text('content_html').notNullable()
+
+ // Visuels
+ table.string('hero_image_url', 500).nullable()
+ table.string('hero_image_alt', 250).nullable()
+ table.string('og_image_url', 500).nullable()
+
+ // Métadonnées éditoriales
+ table.string('author_name', 100).notNullable().defaultTo('Arthur Barré')
+ table.specificType('tags', 'text[]').notNullable().defaultTo('{}')
+
+ // État + publication
+ table
+ .enum('status', ['draft', 'published'], {
+ useNative: true,
+ enumName: 'post_status',
+ })
+ .notNullable()
+ .defaultTo('draft')
+ table.timestamp('published_at', { useTz: true }).nullable()
+
+ // Calculé au save
+ table.integer('reading_time_minutes').notNullable().defaultTo(0)
+ table.integer('word_count').notNullable().defaultTo(0)
+
+ // SEO advanced
+ table.string('canonical_url', 500).nullable()
+ table.boolean('noindex').notNullable().defaultTo(false)
+
+ // Pipeline IA (PR4) — pas de FK pour l'instant, table blog_topics arrive plus tard
+ table.boolean('ai_generated').notNullable().defaultTo(false)
+ table.uuid('ai_topic_id').nullable()
+
+ table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(this.now())
+ table.timestamp('updated_at', { useTz: true }).nullable()
+
+ // Index : la requête publique principale = WHERE status='published' ORDER BY published_at DESC.
+ // Partial index pour ne pas peser sur les drafts (qui ne seront jamais listés publiquement).
+ table.index(['status', 'published_at'], 'posts_published_idx')
+ // Recherche par tag : GIN sur le tableau text[] pour les requêtes "articles liés".
+ this.schema.raw('CREATE INDEX posts_tags_gin_idx ON posts USING GIN (tags)')
+ })
+ }
+
+ async down() {
+ this.schema.raw('DROP INDEX IF EXISTS posts_tags_gin_idx')
+ this.schema.dropTable(this.tableName)
+ this.schema.raw('DROP TYPE IF EXISTS post_status')
+ }
+}
diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts
index 9ecc9c0..b27e9c6 100644
--- a/apps/api/database/schema.ts
+++ b/apps/api/database/schema.ts
@@ -306,6 +306,53 @@ export class PlanSchema extends BaseModel {
declare updatedAt: DateTime | null
}
+export class PostSchema extends BaseModel {
+ static $columns = ['aiGenerated', 'aiTopicId', 'authorName', 'canonicalUrl', 'contentHtml', 'contentMd', 'createdAt', 'excerpt', 'heroImageAlt', 'heroImageUrl', 'id', 'noindex', 'ogImageUrl', 'publishedAt', 'readingTimeMinutes', 'slug', 'status', 'tags', 'title', 'updatedAt', 'wordCount'] as const
+ $columns = PostSchema.$columns
+ @column()
+ declare aiGenerated: boolean
+ @column()
+ declare aiTopicId: string | null
+ @column()
+ declare authorName: string
+ @column()
+ declare canonicalUrl: string | null
+ @column()
+ declare contentHtml: string
+ @column()
+ declare contentMd: string
+ @column.dateTime({ autoCreate: true })
+ declare createdAt: DateTime
+ @column()
+ declare excerpt: string
+ @column()
+ declare heroImageAlt: string | null
+ @column()
+ declare heroImageUrl: string | null
+ @column({ isPrimary: true })
+ declare id: string
+ @column()
+ declare noindex: boolean
+ @column()
+ declare ogImageUrl: string | null
+ @column.dateTime()
+ declare publishedAt: DateTime | null
+ @column()
+ declare readingTimeMinutes: number
+ @column()
+ declare slug: string
+ @column()
+ declare status: any
+ @column()
+ declare tags: any
+ @column()
+ declare title: string
+ @column.dateTime({ autoCreate: true, autoUpdate: true })
+ declare updatedAt: DateTime | null
+ @column()
+ declare wordCount: number
+}
+
export class RefreshTokenSchema extends BaseModel {
static $columns = ['createdAt', 'expiresAt', 'hashedToken', 'id', 'ipAddress', 'lastUsedAt', 'revokedAt', 'updatedAt', 'userAgent', 'userId'] as const
$columns = RefreshTokenSchema.$columns
diff --git a/apps/api/database/seeders/blog_seed/01_retards_paiement.ts b/apps/api/database/seeders/blog_seed/01_retards_paiement.ts
new file mode 100644
index 0000000..0db3add
--- /dev/null
+++ b/apps/api/database/seeders/blog_seed/01_retards_paiement.ts
@@ -0,0 +1,117 @@
+/**
+ * Article fondateur 1/3 — état du marché.
+ * Sert d'ancre SEO sur les requêtes "retards de paiement", "factures impayées
+ * France", "LME indemnités". Aussi few-shot pour le pipeline IA hebdo (PR4).
+ */
+export const article = {
+ slug: '25-pourcent-factures-retard-france',
+ title: '25 % des factures sont payées en retard en France — ce que ça vous coûte vraiment',
+ excerpt:
+ "Une facture sur quatre est payée en retard en France. Pour une TPE-PME, c'est rarement une question de mauvaise foi — c'est une question de coût et d'habitudes.",
+ authorName: 'Arthur Barré',
+ tags: ['trésorerie', 'retards de paiement', 'LME'],
+ publishedDaysAgo: 14,
+ contentMd: `Vous envoyez une facture le 5 du mois. Vous êtes payé le 22. Du **mois suivant**. Sur cinq factures, trois suivent ce schéma. Personne ne crie à la fraude, et pourtant : vos 8 000 € de chiffre d'affaires deviennent 8 000 € de trésorerie immobilisée pendant 47 jours. Cumulé sur l'année, ça représente l'équivalent d'un demi-salaire de salarié bloqué dans le circuit.
+
+C'est la réalité quotidienne d'une TPE-PME française sur quatre.
+
+## Le chiffre qui dérange : 25 %
+
+Selon l'Observatoire des délais de paiement de la Banque de France, environ **un quart des entreprises françaises sont payées en retard**, c'est-à-dire au-delà du délai contractuel négocié. Le retard moyen tourne autour de **12 à 14 jours** en 2024 — un chiffre stable depuis trois ans, malgré le renforcement des contrôles DGCCRF.
+
+Ramené à l'échelle de l'économie, ce sont **plusieurs dizaines de milliards d'euros de trésorerie** qui ne sont pas où ils devraient être. Pour les grandes entreprises, ce flottement est absorbable : leurs lignes de crédit l'épongent. Pour une TPE-PME, c'est une autre histoire.
+
+### Ce que ça représente concrètement pour vous
+
+Prenons un exemple chiffré simple. Vous facturez 200 000 € par an, avec un délai contractuel de 30 jours. Si vos clients paient en moyenne à 45 jours :
+
+- Trésorerie moyenne immobilisée par jour : **548 €**
+- Trésorerie moyenne immobilisée à un instant T : **8 220 €**
+- Si votre découvert vous coûte 6 % : **~493 € par an de coût direct**
+
+C'est peu ? Multiplié par les heures que vous passez à relancer (et qu'on chiffre plus bas), ce n'est plus du tout négligeable.
+
+## Le coût caché : votre temps
+
+Le coût financier direct du retard de paiement est mesurable. Le coût en temps, lui, est rarement mis sur la table — alors que c'est lui qui fait le plus mal en TPE.
+
+Pour une PME qui émet 50 factures par mois et gère ses relances "à la main" :
+
+- **Vérifier les factures impayées** chaque semaine : ~30 min
+- **Relancer par email** chaque facture en retard (rédaction + envoi) : ~5 min × 8 = 40 min
+- **Appeler les clients qui ne répondent pas** : ~15 min × 3 = 45 min
+- **Mettre à jour le suivi** (Excel, comptable) : ~20 min
+
+Soit **environ 2 h 15 par semaine**, ou **9 heures par mois**. Un dirigeant qui se valorise à 60 €/h investit **6 480 € par an** dans des relances qu'il ne devrait pas avoir à faire.
+
+Et ce calcul est optimiste. Il ne compte pas la charge mentale (penser à relancer, hésiter sur la tonalité, vérifier ce qu'on a déjà envoyé), ni les nuits où on se réveille en se demandant si M. Dupont a bien payé.
+
+## Le cadre légal : la LME que personne n'ose brandir
+
+La **loi de modernisation de l'économie (LME)** de 2008 plafonne strictement les délais de paiement entre professionnels :
+
+- **60 jours** maximum à compter de la date d'émission de la facture, **OU**
+- **45 jours fin de mois** si négocié dans les conditions générales de vente.
+
+Au-delà, l'entreprise débitrice est en infraction. Les sanctions sont lourdes :
+
+- **Amende administrative jusqu'à 2 millions d'euros** pour une personne morale
+- **75 000 €** pour une personne physique
+- Sanctions doublées en cas de récidive
+
+La DGCCRF a renforcé ses contrôles depuis 2023, et la liste des entreprises sanctionnées est désormais **publiée**, ce qui ajoute un risque réputationnel non négligeable.
+
+**Et pourtant** : combien de TPE-PME osent rappeler la LME à leur grand client retardataire ? Très peu. Parce que c'est l'arme nucléaire, et qu'on a peur de perdre le compte.
+
+## L'arme oubliée : les indemnités automatiques
+
+Bien plus utiles au quotidien, deux mécanismes prévus par l'**article L441-10 du Code de commerce** sont **automatiques** dès le premier jour de retard, sans qu'aucun rappel ou mise en demeure ne soit nécessaire :
+
+1. **Indemnité forfaitaire de 40 €** pour frais de recouvrement, par facture en retard (Décret n° 2012-1115).
+2. **Pénalités de retard** au taux d'intérêt de la BCE majoré de 10 points (soit ~14 % en 2024).
+
+Concrètement : si votre client paie une facture de 5 000 € avec 20 jours de retard, vous êtes en droit de réclamer **40 € + ~38 € de pénalités = 78 €**. Et il vous doit ces 78 € — légalement, automatiquement.
+
+Le mentionner clairement sur vos factures et vos CGV est un puissant levier dissuasif. Pas besoin d'aller au tribunal, juste de rappeler le droit.
+
+## Pourquoi les retards persistent (ce n'est pas la mauvaise foi)
+
+Si la loi est si claire, pourquoi 25 % des factures sont-elles encore payées en retard ?
+
+**1. La culture du "fin de mois".** En France, beaucoup d'entreprises ont des cycles de paiement mensuels figés (le 25 ou le 30). Une facture émise le 16 attendra mécaniquement le mois suivant pour être réglée.
+
+**2. La file d'attente comptable.** Plus le client est gros, plus sa comptabilité est lente. Une facture sans bon de commande ou sans matricule attendra la régularisation, qui prendra trois semaines.
+
+**3. Le retard "stratégique".** Certains acheteurs jouent volontairement la montre pour optimiser leur propre trésorerie, pariant que vous ne réclamerez pas.
+
+**4. Le simple oubli.** Banal, mais dominant. Votre facture est noyée dans les 200 du mois. Personne ne pense à elle si vous ne le rappelez pas.
+
+Dans les quatre cas, **la solution est la même** : un rappel envoyé au bon moment, avec la bonne tonalité, débloque la situation. Sans drame, sans procédure.
+
+## Reprendre la main, sans devenir agressif
+
+Trois principes simples permettent à une TPE-PME de **diviser par deux** son délai moyen de paiement, sans abîmer aucune relation commerciale :
+
+### 1. Relancer dès J+3, systématiquement
+
+Une relance courte trois jours après l'échéance n'est pas perçue comme agressive — elle est perçue comme professionnelle. Elle évite le risque de "l'oubli" et signale au client que votre cycle de cash est suivi.
+
+### 2. Escalader la tonalité progressivement
+
+J+3 = ton chaleureux, "petit rappel". J+10 = ton plus ferme, factuel. J+20 = ton de pré-contentieux, mention des indemnités. Une seule relance "amicale" suivie d'un silence d'un mois ne fonctionne pas. La cadence et l'évolution du ton font tout.
+
+### 3. Toujours rappeler le contexte chiffré
+
+Numéro de facture, montant, date d'échéance, jours de retard. Pas d'ambiguïté possible, pas de "je vais vérifier" qui sert d'excuse pour gagner du temps.
+
+## En résumé
+
+Les retards de paiement ne sont ni une fatalité, ni une caractéristique culturelle française qu'il faudrait subir. Ce sont **des frictions de processus** qui se résolvent avec :
+
+- De la **systématisation** (relancer chaque facture, pas seulement les grosses)
+- Du **timing** (commencer J+3, pas J+15)
+- De la **constance** (cadencer plusieurs étapes, pas une seule)
+
+C'est exactement ce que Rubis automatise : vous définissez votre plan de relance une fois, et chaque facture suit son rythme sans que vous y pensiez. Vous récupérez en moyenne **5 heures par semaine** que vous remettez sur ce qui a vraiment de la valeur — votre métier.
+`,
+}
diff --git a/apps/api/database/seeders/blog_seed/02_relancer_sans_casser.ts b/apps/api/database/seeders/blog_seed/02_relancer_sans_casser.ts
new file mode 100644
index 0000000..f111325
--- /dev/null
+++ b/apps/api/database/seeders/blog_seed/02_relancer_sans_casser.ts
@@ -0,0 +1,113 @@
+/**
+ * Article fondateur 2/3 — playbook relation client.
+ * Cible les recherches "comment relancer", "relance facture client",
+ * "ton relance email". Maillage interne avec articles 1 et 3.
+ */
+export const article = {
+ slug: 'relancer-client-sans-casser-relation-commerciale',
+ title: 'Comment relancer un client sans casser la relation commerciale',
+ excerpt:
+ "Relancer une facture impayée n'est pas une agression — c'est de la rigueur. Voici la méthode pour réclamer son dû sans abîmer la relation, avec le bon timing et la bonne tonalité.",
+ authorName: 'Arthur Barré',
+ tags: ['relance', 'relation client', 'communication'],
+ publishedDaysAgo: 7,
+ contentMd: `> *La peur de perdre le client retient des milliers d'euros bloqués chaque mois en France. Pourtant, bien menée, une relance ne casse rien — elle structure.*
+
+C'est l'angoisse récurrente du dirigeant de TPE-PME : "Si je relance, je vais le braquer. Et c'est mon plus gros client." Alors la relance traîne. Une semaine. Trois. Six. La facture finit par être payée — souvent en grognant — ou disparaît dans les limbes du *"je n'ai jamais reçu"*.
+
+Cette peur est compréhensible. Elle est aussi presque toujours infondée.
+
+## Relancer, ce n'est pas agresser
+
+Un client professionnel qui paie en retard sait qu'il paie en retard. Dans 90 % des cas, il attend simplement votre rappel pour traiter votre facture parmi les dizaines qui passent dans son service comptable. La relance ne le surprend pas. Elle ne le vexe pas. Elle le **débloque**.
+
+Ce qui peut casser la relation, ce n'est pas la relance — c'est **la mauvaise relance** :
+
+- Une relance trop tardive qui arrive avec un ton accusateur (parce qu'on a accumulé la frustration).
+- Une relance ambiguë où on s'excuse à moitié et on supplie à moitié.
+- Une relance par téléphone à 17 h 59 un vendredi.
+- Un copier-coller mal personnalisé avec le nom d'un autre client.
+
+Bien menée, la relance fait partie du dialogue commercial normal. Mal menée, elle devient une crise.
+
+## Trois dimensions à doser : timing, tonalité, canal
+
+### Le timing
+
+La règle d'or : **relancer tôt, et systématiquement**.
+
+- **J+3 après échéance** : premier rappel cordial. C'est tôt — exprès. À ce stade, personne ne se sent attaqué. Le ton est presque neutre : *"petit rappel, on n'a pas reçu votre règlement, peut-être un oubli ?"*
+- **J+10** : deuxième relance, plus ferme et factuelle. On rappelle les éléments (numéro, montant, échéance), on demande explicitement une date de paiement.
+- **J+20** : troisième relance, ton sec et professionnel. Mention possible des indemnités de retard prévues par la loi.
+- **J+30 ou J+45** : mise en demeure. À ce stade, on sort du dialogue normal — c'est un acte juridique préalable à un éventuel contentieux.
+
+Plus on attend, plus la première relance devient difficile à formuler. À J+45 sans rien envoyer, vous êtes obligé de durcir le ton pour rattraper le retard, ce qui rend la relation crispée. À J+3, vous restez naturel.
+
+### La tonalité
+
+Quatre tons graduels, dans l'ordre :
+
+| Étape | Ton | Vocabulaire-clé |
+|---|---|---|
+| Rappel J+3 | Chaleureux, neutre | "petit rappel", "il s'agit peut-être d'un oubli", "n'hésitez pas si question" |
+| Relance J+10 | Ferme, factuel | "je vous remercie de bien vouloir procéder", "merci de me confirmer une date" |
+| Pré-contentieux J+20 | Sec, professionnel | "en l'absence de règlement", "indemnités forfaitaires de 40 €", "pénalités de retard" |
+| Mise en demeure | Juridique | "mise en demeure", "à défaut sous 8 jours", "procédure contentieuse" |
+
+L'erreur la plus fréquente est de **mélanger les registres** : être amical au mauvais moment ou agressif trop tôt. La progression doit être visible et naturelle — votre client comprend que vous montez d'un cran, et il sait pourquoi.
+
+### Le canal
+
+- **Email** : le canal par défaut. Trace écrite, archivable, peu intrusif. **95 % des relances doivent passer par là.**
+- **Téléphone** : à utiliser quand l'email reste sans réponse depuis deux relances, et qu'il faut "humaniser" la situation. Préparer un script court, noter les engagements pris, **toujours envoyer un email de confirmation** dans la foulée.
+- **Courrier recommandé** : réservé à la mise en demeure. Coût ~6 €, mais c'est la valeur juridique qui compte. Sans AR signé, vous n'aurez rien à présenter en cas de litige.
+- **SMS** : à éviter sauf relation vraiment proche. Trop intrusif, perçu comme agressif en B2B.
+
+## L'escalade en pratique
+
+Voici comment se déroule une cadence type sur une facture de 3 200 € émise le 1er janvier, échéance 31 janvier :
+
+- **3 février** — Email cordial. *"Bonjour M. Dupont, petit rappel concernant la facture F-2025-014 (3 200 €) échue le 31 janvier. Il s'agit peut-être d'un oubli — n'hésitez pas si vous avez besoin d'un duplicata. Bonne journée !"*
+- **10 février** — Email ferme. *"Bonjour, sauf erreur, le règlement de la facture F-2025-014 ne nous est pas encore parvenu. Pourriez-vous m'indiquer une date de règlement ? Merci d'avance."*
+- **20 février** — Email pré-contentieux. *"Bonjour, à ce jour la facture F-2025-014 reste impayée malgré nos relances. Sans règlement sous 7 jours, nous serons contraints d'appliquer les indemnités forfaitaires (40 €) et pénalités de retard prévues par l'article L441-10 du Code de commerce."*
+- **5 mars** — Mise en demeure par recommandé AR. *Lettre formelle d'une page, mention "MISE EN DEMEURE", délai de 8 jours, mention de la procédure contentieuse en cas de non-règlement.*
+
+Dans 80 % des cas, le règlement arrive entre la deuxième et la troisième relance. Les 20 % restants sont les vrais litiges — et là, vous avez besoin de la **preuve écrite** que vous avez relancé proprement.
+
+## Les 5 erreurs qui cassent vraiment la relation
+
+**1. Relancer pour la première fois à J+45.** Vous avez accumulé l'agacement. Votre première relance est sèche. Le client se sent agressé d'un coup, alors qu'il n'avait jamais été prévenu. **C'est l'erreur n°1.**
+
+**2. S'excuser de relancer.** *"Désolé de vous embêter avec ça mais..."* — Vous n'avez pas à être désolé. Le client vous doit de l'argent. Une relance professionnelle n'a pas à se justifier.
+
+**3. Personnaliser émotionnellement.** *"Vous me mettez dans une situation difficile"* ou *"j'ai des fournisseurs à payer"*. Vrai, mais ce n'est pas l'affaire du client. Restez factuel : numéro, montant, échéance, retard.
+
+**4. Mélanger relance et autre sujet.** *"Au fait, sur le projet de mai, on pourrait faire X"* en bas du même email. Le sujet relance se dilue. **Faites des emails séparés.**
+
+**5. Téléphoner avant d'avoir une trace écrite.** Sans email préalable, le client peut nier. *"Personne ne m'a rien dit."* Toujours laisser une trace écrite avant de passer au téléphone.
+
+## Quand passer la main
+
+À J+45 sans réponse à la mise en demeure, **arrêtez de relancer vous-même**. Continuer ne sert plus à rien et vous expose juridiquement (acharnement, harcèlement caractérisé selon l'intensité).
+
+Trois options s'offrent à vous :
+
+- **Société de recouvrement amiable** : 5 à 15 % de commission sur le recouvré. Bonne option pour les factures supérieures à 1 500 €.
+- **Injonction de payer** : procédure simplifiée au tribunal de commerce. Coût ~50 €, délai 1 à 3 mois, redoutablement efficace pour les créances incontestées.
+- **Avocat** : si litige commercial réel (contestation du service rendu, etc.). Coût plus élevé, à mobiliser pour les factures supérieures à 5 000 €.
+
+Dans tous les cas, **votre dossier de relances proprement archivé** sera votre arme principale. Sans lui, le juge ou le médiateur n'a rien sur quoi s'appuyer.
+
+## En résumé
+
+Relancer ne casse pas la relation. **Ne pas relancer** la casse — parce que vous accumulez la frustration et que la première fois où vous ouvrez la bouche, c'est tendu. La méthode :
+
+- **Tôt** (J+3, pas J+30)
+- **Cadencé** (3 à 4 étapes, pas une seule)
+- **Tonalité progressive** (chaleureux → ferme → pré-contentieux → juridique)
+- **Email d'abord, téléphone en renfort, recommandé pour la mise en demeure**
+- **Toujours factuel** (numéro, montant, échéance, retard)
+
+Vos clients ne vous en voudront pas. Ceux qui s'en offusquent cherchaient probablement à ne pas payer.
+`,
+}
diff --git a/apps/api/database/seeders/blog_seed/03_modeles_email.ts b/apps/api/database/seeders/blog_seed/03_modeles_email.ts
new file mode 100644
index 0000000..a4cfe50
--- /dev/null
+++ b/apps/api/database/seeders/blog_seed/03_modeles_email.ts
@@ -0,0 +1,187 @@
+/**
+ * Article fondateur 3/3 — templates copiables.
+ * Ancré sur "modèle email relance", "template relance facture",
+ * "exemple email relance impayé". Le plus actionnable des trois — sera
+ * probablement le plus partagé / sauvegardé.
+ */
+export const article = {
+ slug: '5-modeles-email-relance-qui-marchent',
+ title: "5 modèles d'email de relance qui marchent vraiment",
+ excerpt:
+ "Cinq emails de relance prêts à copier-coller, calibrés pour les TPE-PME françaises. Du rappel cordial J+3 à la mise en demeure, avec les variables à personnaliser.",
+ authorName: 'Arthur Barré',
+ tags: ['templates', 'email', 'relance'],
+ publishedDaysAgo: 1,
+ contentMd: `Un email de relance qui fonctionne tient en trois critères : **objet clair, contexte chiffré, demande explicite**. Pas plus. Voici cinq templates calibrés pour les TPE-PME françaises, à copier-coller et à personnaliser.
+
+## Avant de copier : les 5 règles transverses
+
+Tous ces emails respectent la même grammaire :
+
+1. **L'objet contient le mot "facture" et le numéro.** *"Rappel facture F-2025-014"* — pas *"Petit message"* ni *"Bonjour"*. Les filtres anti-spam et les inbox encombrées passent dessus.
+2. **Le numéro, le montant et l'échéance apparaissent dans les trois premières lignes.** Pas dans une PJ, pas dans un tableau. Texte brut visible immédiatement.
+3. **La demande est explicite et fermée.** *"Pouvez-vous m'indiquer une date de règlement avant vendredi ?"* — pas *"Pourriez-vous me dire ce qu'il en est ?"*
+4. **La signature inclut votre numéro de téléphone.** Si le client veut clarifier, il doit pouvoir vous appeler en un clic.
+5. **Pas de pièce jointe au premier rappel.** Si le client a égaré la facture, c'est l'occasion qu'il vous le dise — vous renverrez à J+10.
+
+---
+
+## Template 1 — J+3 : le rappel cordial
+
+**Quand l'envoyer** : 3 jours après la date d'échéance. Tonalité neutre, presque administrative. C'est un rappel, pas une réclamation.
+
+**Objet** : \`Rappel — facture {{numero}}\`
+
+**Corps** :
+
+> Bonjour {{prenom}},
+>
+> Petit rappel concernant la facture **{{numero}}** d'un montant de **{{montant}} €**, échue le {{date_echeance}}.
+>
+> Sauf erreur de ma part, je n'ai pas trace de votre règlement. Il s'agit peut-être d'un simple oubli ou d'un délai côté comptabilité — n'hésitez pas si vous avez besoin d'un duplicata ou d'une précision.
+>
+> Bonne journée,
+>
+> {{signature}}
+
+**Ce qui le fait fonctionner** :
+
+- Le mot "rappel" dédramatise immédiatement.
+- "Sauf erreur de ma part" laisse une porte de sortie au client (ce qui le détend).
+- "Peut-être un oubli" attribue une cause neutre (pas une accusation).
+- Aucune mention d'indemnités à ce stade — ce serait disproportionné.
+
+---
+
+## Template 2 — J+10 : la relance ferme
+
+**Quand l'envoyer** : 10 jours après échéance, en l'absence de réponse au premier rappel. La tonalité monte d'un cran : factuel, professionnel, pas hostile.
+
+**Objet** : \`Facture {{numero}} — règlement en attente\`
+
+**Corps** :
+
+> Bonjour {{prenom}},
+>
+> Je reviens vers vous concernant la facture **{{numero}}** de **{{montant}} €**, échue le {{date_echeance}} et toujours en attente de règlement à ce jour ({{jours_retard}} jours de retard).
+>
+> Pourriez-vous me confirmer une date de paiement ? Si la facture nécessite un duplicata ou un complément d'information, je vous l'envoie immédiatement.
+>
+> Je vous remercie d'avance pour votre retour.
+>
+> {{signature}}
+
+**Ce qui le fait fonctionner** :
+
+- Le nombre de jours de retard est mentionné explicitement. Aucun doute possible.
+- "Confirmer une date" est une demande **fermée** — le client doit y répondre par une date, pas par un atermoiement.
+- Maintien de la coopération : "je vous l'envoie immédiatement" si problème.
+- Toujours pas de menace à ce stade — on garde un espace de dialogue.
+
+---
+
+## Template 3 — J+20 : l'avant-mise-en-demeure
+
+**Quand l'envoyer** : 20 jours après échéance, après deux relances ignorées. C'est la dernière chance avant procédure formelle. Le ton bascule du commercial au juridique.
+
+**Objet** : \`Facture {{numero}} — relance avant pénalités\`
+
+**Corps** :
+
+> Bonjour {{prenom}},
+>
+> Malgré mes précédents rappels, la facture **{{numero}}** ({{montant}} €) reste impayée à ce jour, soit {{jours_retard}} jours de retard.
+>
+> Sans règlement sous **7 jours**, je serai contraint d'appliquer :
+> - L'indemnité forfaitaire de recouvrement de **40 €** prévue par le décret n°2012-1115 ;
+> - Les pénalités de retard au taux BCE + 10 points (article L441-10 du Code de commerce).
+>
+> Je reste à votre disposition pour toute précision ou pour convenir d'un échéancier si nécessaire.
+>
+> Cordialement,
+>
+> {{signature}}
+
+**Ce qui le fait fonctionner** :
+
+- La référence légale précise (décret + article) signale que vous connaissez le cadre. Le client comprend que vous n'improvisez pas.
+- L'ouverture sur un échéancier en fin d'email maintient la porte ouverte — souvent, le client paie partiellement, et c'est mieux que rien.
+- Délai de 7 jours explicite : pas d'ambiguïté sur la suite.
+- Le mot "mise en demeure" n'apparaît pas encore — on le réserve pour le recommandé.
+
+---
+
+## Template 4 — La mise en demeure (recommandé AR)
+
+**Quand l'envoyer** : à l'issue du délai de 7 jours du template 3, sans règlement. **Toujours par courrier recommandé avec accusé de réception**, jamais par email seul. Sans AR signé, vous n'aurez aucune preuve juridique.
+
+**Objet du courrier** : \`MISE EN DEMEURE — facture {{numero}}\`
+
+**Corps** :
+
+> Madame, Monsieur {{nom}},
+>
+> Par la présente, je vous **mets en demeure** de procéder au règlement de la facture **{{numero}}** d'un montant de **{{montant}} €**, échue le {{date_echeance}} et restée impayée malgré mes relances des {{date_relance_1}}, {{date_relance_2}} et {{date_relance_3}}.
+>
+> À défaut de règlement intégral sous **8 jours** à compter de la réception de la présente, je serai contraint d'engager toute procédure utile pour le recouvrement de ma créance, en ce compris :
+>
+> - Une procédure d'injonction de payer auprès du tribunal de commerce ;
+> - L'application des indemnités forfaitaires (40 €) et pénalités de retard légales ;
+> - Le recouvrement par voie d'huissier le cas échéant.
+>
+> Cette mise en demeure fait courir les intérêts de retard au taux légal majoré, conformément à l'article 1231-6 du Code civil.
+>
+> Je vous prie de croire, Madame, Monsieur, en l'expression de mes salutations distinguées.
+>
+> {{signature}}
+
+**Ce qui le fait fonctionner** :
+
+- Le terme "mise en demeure" est juridiquement contraignant : il fait courir les intérêts au taux majoré, et constitue le préalable obligatoire à toute procédure contentieuse.
+- L'historique des relances est mentionné — vous prouvez votre patience.
+- Les options évoquées (injonction, huissier) sont concrètes et chiffrables. Le client comprend que ce n'est plus du bluff.
+- Le ton reste professionnel : pas d'insulte, pas de menace personnelle.
+
+---
+
+## Template 5 — Le merci post-paiement
+
+**Quand l'envoyer** : dans les 24 h suivant la réception du paiement. C'est le template que tout le monde oublie — alors que c'est celui qui **répare la relation**.
+
+**Objet** : \`Bien reçu — facture {{numero}}\`
+
+**Corps** :
+
+> Bonjour {{prenom}},
+>
+> Je vous confirme la bonne réception du règlement de la facture **{{numero}}**.
+>
+> Merci, et bonne fin de semaine.
+>
+> {{signature}}
+
+**Ce qui le fait fonctionner** :
+
+- Court, sans surenchère ni rappel des relances passées. On tourne la page.
+- Confirmation explicite : le client n'a pas à se demander si vous avez bien encaissé.
+- Le retour à un ton chaleureux signale que la relation reprend son cours normal.
+
+C'est aussi le bon moment pour glisser, le mois suivant, *"d'ailleurs, on a sorti une nouvelle prestation X qui pourrait vous intéresser..."* — la relation est de nouveau alignée.
+
+---
+
+## Variables à automatiser
+
+Si vous gérez plus de 5 factures par mois, copier-coller manuellement ces templates devient vite fastidieux. Les variables à personnaliser dans chaque envoi :
+
+- \`{{prenom}}\`, \`{{nom}}\` — interlocuteur côté client
+- \`{{numero}}\` — numéro de facture
+- \`{{montant}}\` — montant TTC en euros
+- \`{{date_echeance}}\` — date d'échéance contractuelle
+- \`{{jours_retard}}\` — calcul automatique vs. aujourd'hui
+- \`{{date_relance_1}}\`, \`{{date_relance_2}}\` — historique des relances précédentes
+- \`{{signature}}\` — bloc-signature avec téléphone
+
+C'est exactement ce que Rubis automatise. Vous configurez la cadence et les templates une seule fois ; le système les envoie au bon moment avec les bonnes variables, et vous récupérez en moyenne 5 heures de travail par semaine.
+`,
+}
diff --git a/apps/api/database/seeders/blog_seed/index.ts b/apps/api/database/seeders/blog_seed/index.ts
new file mode 100644
index 0000000..cb678a3
--- /dev/null
+++ b/apps/api/database/seeders/blog_seed/index.ts
@@ -0,0 +1,7 @@
+import { article as article1 } from './01_retards_paiement.js'
+import { article as article2 } from './02_relancer_sans_casser.js'
+import { article as article3 } from './03_modeles_email.js'
+
+export type SeedArticle = typeof article1
+
+export const seedArticles: SeedArticle[] = [article1, article2, article3]
diff --git a/apps/api/package.json b/apps/api/package.json
index c0aaad3..5c32d9b 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -86,6 +86,7 @@
"bullmq": "^5.76.5",
"ioredis": "^5.10.1",
"luxon": "^3.7.2",
+ "marked": "^15.0.12",
"pg": "^8.20.0",
"react": "^19.2.5",
"reflect-metadata": "^0.2.2",
diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts
index 4fba853..387507b 100644
--- a/apps/api/start/routes.ts
+++ b/apps/api/start/routes.ts
@@ -13,6 +13,13 @@ import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
+/**
+ * Blog public — endpoints JSON consommés par apps/landing (Astro SSR).
+ * Pas auth, pas de paginiation V1 (volume cible <100 articles).
+ */
+const BlogController = () => import('#controllers/blog_controller')
+
+
router
.group(() => {
/**
@@ -35,6 +42,19 @@ router
throw new Error(`Sentry test from rubis-api — ${new Date().toISOString()}`)
})
}
+
+ /**
+ * Blog — endpoints JSON publics consommés par apps/landing en SSR.
+ * Cf. apps/landing/src/pages/blog/*.astro pour la consommation et
+ * apps/api/app/controllers/blog_controller.ts pour l'implémentation.
+ */
+ router
+ .group(() => {
+ router.get('', [BlogController, 'index']).as('index')
+ router.get(':slug', [BlogController, 'show']).as('show')
+ })
+ .prefix('posts')
+ .as('posts')
})
.prefix('/api/v1')
diff --git a/apps/api/start/sentry.ts b/apps/api/start/sentry.ts
index ee25acf..b4e33c7 100644
--- a/apps/api/start/sentry.ts
+++ b/apps/api/start/sentry.ts
@@ -8,20 +8,27 @@
| d'Adonis n'est pas encore chargé à ce stade.
|
| Si SENTRY_DSN_API n'est pas défini (dev local typique), Sentry est
-| simplement no-op — aucun appel réseau.
+| simplement no-op — aucun appel réseau, et `@sentry/profiling-node`
+| n'est PAS importé (l'import eager déclenche le chargement de la
+| binary native CPU profiler, qui n'est pas dispo pour toutes les
+| versions Node — ex. Node 25 sur darwin-arm64).
|
| Cf. ADR-024 — choix Sentry SaaS, free tier, 2 projects
| (rubis-api / rubis-web).
*/
import * as Sentry from '@sentry/node'
-import { nodeProfilingIntegration } from '@sentry/profiling-node'
const dsn = process.env.SENTRY_DSN_API
const environment = process.env.NODE_ENV ?? 'development'
const release = process.env.APP_VERSION ?? 'dev'
if (dsn) {
+ // Import dynamique : on ne charge le binding C++ que quand on en a
+ // vraiment besoin (prod avec DSN). En dev, l'absence de binaire pour
+ // l'ABI Node courant n'est pas un problème.
+ const { nodeProfilingIntegration } = await import('@sentry/profiling-node')
+
Sentry.init({
dsn,
environment,
diff --git a/apps/landing/.env.development b/apps/landing/.env.development
new file mode 100644
index 0000000..e48ae1a
--- /dev/null
+++ b/apps/landing/.env.development
@@ -0,0 +1 @@
+API_URL=http://localhost:3333
diff --git a/apps/landing/.env.example b/apps/landing/.env.example
new file mode 100644
index 0000000..bce9521
--- /dev/null
+++ b/apps/landing/.env.example
@@ -0,0 +1,4 @@
+# URL de base de apps/api — utilisée en SSR pour fetcher /api/v1/posts.
+# En dev local : http://localhost:3333
+# En prod (K3s ConfigMap) : https://app.rubis.pro
+API_URL=http://localhost:3333
diff --git a/apps/landing/.gitignore b/apps/landing/.gitignore
new file mode 100644
index 0000000..481d754
--- /dev/null
+++ b/apps/landing/.gitignore
@@ -0,0 +1,15 @@
+# Build & cache Astro
+dist/
+.astro/
+
+# Node
+node_modules/
+
+# Env
+.env
+.env.local
+.env.*.local
+
+# Logs / OS
+*.log
+.DS_Store
diff --git a/apps/landing/astro.config.mjs b/apps/landing/astro.config.mjs
new file mode 100644
index 0000000..cc95d9e
--- /dev/null
+++ b/apps/landing/astro.config.mjs
@@ -0,0 +1,36 @@
+// @ts-check
+import { defineConfig } from "astro/config";
+import react from "@astrojs/react";
+import node from "@astrojs/node";
+import tailwindcss from "@tailwindcss/vite";
+
+/**
+ * Astro 6 — landing publique + blog rubis.pro.
+ *
+ * Stratégie de rendu (cf. /docs/tech/architecture.md §3) :
+ * - `output: "server"` → SSR par défaut, on opt-out par page avec
+ * `export const prerender = true;` pour les pages statiques.
+ * - Landing + pages légales : `prerender = true` (HTML figé au build, ultra-rapide).
+ * - Blog (/blog, /blog/:slug) : SSR pur, fetch Adonis API à la requête, cache
+ * HTTP côté Traefik / nginx pour absorber les pics — publish admin = immédiat.
+ *
+ * Adapter Node standalone : produit un serveur autonome dans `dist/server/`,
+ * démarré par `node ./dist/server/entry.mjs` dans le container K3s.
+ */
+export default defineConfig({
+ site: "https://rubis.pro",
+ output: "server",
+ adapter: node({
+ mode: "standalone",
+ }),
+ integrations: [
+ react(),
+ ],
+ vite: {
+ plugins: [tailwindcss()],
+ },
+ server: {
+ host: "0.0.0.0",
+ port: 5174,
+ },
+});
diff --git a/apps/landing/package.json b/apps/landing/package.json
new file mode 100644
index 0000000..1f408ca
--- /dev/null
+++ b/apps/landing/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@rubis/landing",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Landing publique rubis.pro + blog (Astro 6 SSR/SSG hybride, React 19 + @rubis/ui).",
+ "scripts": {
+ "dev": "astro dev --port 5174",
+ "build": "astro build",
+ "preview": "astro preview --port 5174",
+ "start": "node ./dist/server/entry.mjs",
+ "lint": "eslint .",
+ "typecheck": "astro check"
+ },
+ "dependencies": {
+ "@astrojs/check": "^0.9.4",
+ "@astrojs/node": "^10.1.0",
+ "@astrojs/react": "^5.0.4",
+ "@fontsource-variable/bricolage-grotesque": "^5.2.5",
+ "@fontsource-variable/inter": "^5.2.5",
+ "@rubis/shared": "workspace:*",
+ "@rubis/ui": "workspace:*",
+ "@tailwindcss/vite": "^4.1.0",
+ "astro": "^6.3.1",
+ "lucide-react": "^0.475.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "tailwindcss": "^4.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "~6.0.2"
+ }
+}
diff --git a/landing/apple-touch-icon.png b/apps/landing/public/apple-touch-icon.png
similarity index 100%
rename from landing/apple-touch-icon.png
rename to apps/landing/public/apple-touch-icon.png
diff --git a/landing/favicon-96x96.png b/apps/landing/public/favicon-96x96.png
similarity index 100%
rename from landing/favicon-96x96.png
rename to apps/landing/public/favicon-96x96.png
diff --git a/landing/favicon.ico b/apps/landing/public/favicon.ico
similarity index 100%
rename from landing/favicon.ico
rename to apps/landing/public/favicon.ico
diff --git a/landing/favicon.svg b/apps/landing/public/favicon.svg
similarity index 100%
rename from landing/favicon.svg
rename to apps/landing/public/favicon.svg
diff --git a/landing/site.webmanifest b/apps/landing/public/site.webmanifest
similarity index 100%
rename from landing/site.webmanifest
rename to apps/landing/public/site.webmanifest
diff --git a/landing/web-app-manifest-192x192.png b/apps/landing/public/web-app-manifest-192x192.png
similarity index 100%
rename from landing/web-app-manifest-192x192.png
rename to apps/landing/public/web-app-manifest-192x192.png
diff --git a/landing/web-app-manifest-512x512.png b/apps/landing/public/web-app-manifest-512x512.png
similarity index 100%
rename from landing/web-app-manifest-512x512.png
rename to apps/landing/public/web-app-manifest-512x512.png
diff --git a/apps/landing/src/components/SiteFooter.tsx b/apps/landing/src/components/SiteFooter.tsx
new file mode 100644
index 0000000..4a82c3d
--- /dev/null
+++ b/apps/landing/src/components/SiteFooter.tsx
@@ -0,0 +1,50 @@
+import { Brand } from "@rubis/ui";
+
+const CURRENT_YEAR = new Date().getFullYear();
+
+/**
+ * Footer public commun à toutes les pages rubis.pro/*.
+ * Liens légaux + tagline. Pas de réseaux sociaux V1.
+ */
+export function SiteFooter() {
+ return (
+
+ );
+}
diff --git a/apps/landing/src/components/SiteHeader.tsx b/apps/landing/src/components/SiteHeader.tsx
new file mode 100644
index 0000000..a7fd11a
--- /dev/null
+++ b/apps/landing/src/components/SiteHeader.tsx
@@ -0,0 +1,56 @@
+import { Brand, Button, cn } from "@rubis/ui";
+
+const APP_URL = "https://app.rubis.pro";
+
+type SiteHeaderProps = {
+ /** Si true, fond opaque + bordure (utile sur les pages blog où on n'a pas de hero). Sinon transparent + sticky-blur. */
+ solid?: boolean;
+ className?: string;
+};
+
+/**
+ * Header public commun à toutes les pages rubis.pro/* :
+ * - lockup brand → /
+ * - liens nav (Tarifs, Blog)
+ * - CTA "Essai gratuit 30 j" → app.rubis.pro
+ *
+ * Sticky avec backdrop-blur quand le header est posé sur un hero (transparent),
+ * solid+bordure sur les pages secondaires (blog, légal).
+ */
+export function SiteHeader({ solid = false, className }: SiteHeaderProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/sections/FAQ.tsx b/apps/landing/src/components/sections/FAQ.tsx
new file mode 100644
index 0000000..65b820b
--- /dev/null
+++ b/apps/landing/src/components/sections/FAQ.tsx
@@ -0,0 +1,138 @@
+import { Eyebrow } from "@rubis/ui";
+
+const FAQS: Array<{ q: string; a: React.ReactNode }> = [
+ {
+ q: "Et si mon client paie hors plateforme — comment Rubis le sait ?",
+ a: (
+ <>
+ Avant chaque relance, Rubis vous envoie un email rapide :{" "}
+ « Avez-vous été payé pour la facture F-2024-0042 ? » avec deux boutons. Vous
+ cliquez "Oui" en 3 secondes, le plan s'arrête. Vous cliquez "Non" (ou ne répondez pas),
+ la relance part comme prévu. Vous configurez la cadence et le timing de ces
+ vérifications dans vos plans.
+ >
+ ),
+ },
+ {
+ q: "Mes factures et données restent-elles privées ?",
+ a: (
+ <>
+ Évidemment. Hébergement français, conforme RGPD. Vos PDF sont stockés chiffrés. Aucune
+ donnée n'est partagée avec des tiers. Vous pouvez exporter ou supprimer vos données à
+ tout moment.
+ >
+ ),
+ },
+ {
+ q: "Puis-je personnaliser le contenu des emails ?",
+ a: (
+ <>
+ Oui, dès le plan Pro. Tous les emails sont des templates avec variables (
+ {"{{prenom_client}}"},{" "}
+ {"{{numero}}"},{" "}
+ {"{{montant}}"}…).
+ Vous pouvez réécrire chaque étape, ajuster le ton, ajouter votre signature email et
+ votre logo.
+ >
+ ),
+ },
+ {
+ q: "Mes clients verront-ils que j'utilise Rubis ?",
+ a: (
+ <>
+ Pas vraiment. En plan Pro, vos clients voient votre nom en grand comme
+ expéditeur, et quand ils cliquent « Répondre », leur message revient directement sur{" "}
+ votre email. Aucun pied de page ne mentionne Rubis. Suffisant pour 95 % des
+ freelances et TPE.
+
+
+ En plan Business, on va plus loin : vos emails partent vraiment depuis{" "}
+ votre propre adresse (
+
+ compta@votre-entreprise.fr
+
+ ). Personne ne devine que vous utilisez un outil, et vos relances atterrissent{" "}
+ mieux en boîte principale plutôt qu'en spam ou en promotions — gain typique de 10
+ à 15 % sur le taux d'ouverture.
+ >
+ ),
+ },
+ {
+ q: "Et si je veux relancer manuellement, sans plan ?",
+ a: (
+ <>
+ Toujours possible. Sur n'importe quelle facture, vous avez un bouton "Relancer
+ maintenant" qui envoie un email immédiat. Pratique quand vous venez d'avoir le client au
+ téléphone et qu'il vous a demandé un récapitulatif.
+ >
+ ),
+ },
+ {
+ q: "Et la mise en demeure, elle part toute seule ?",
+ a: (
+ <>
+ Non. Jamais. C'est une décision produit forte : la mise en demeure a des conséquences
+ légales et relationnelles importantes. Rubis prépare le brouillon à l'étape prévue de
+ votre plan, vous notifie, et c'est vous qui cliquez "Envoyer" sur une modale de
+ confirmation. Vous gardez la main sur le moment où le ton change vraiment.
+ >
+ ),
+ },
+ {
+ q: "Combien de temps pour démarrer ?",
+ a: (
+ <>
+ Inscription en 30 secondes. Configuration de votre signature email et de votre première
+ facture en 5 minutes. La première relance peut partir dans la foulée. Si vous avez un
+ plan par défaut bien configuré, créer une nouvelle facture en relance prend{" "}
+ 2 clics.
+ >
+ ),
+ },
+ {
+ q: 'Pourquoi cette histoire de "rubis" ?',
+ a: (
+ <>
+ Parce que les chiffres comptables (DSO, taux de recouvrement, AR aging) ne réveillent
+ personne le matin. Le temps gagné, oui. 1 rubis = 10 minutes libérées = 1 relance
+ que vous n'avez pas eu à écrire. À la fin du mois, vous voyez "124 rubis ≈ 24 h 48".
+ C'est concret. Et c'est plus fun que de regarder un graphique de courbes.
+ >
+ ),
+ },
+];
+
+export function FAQ() {
+ return (
+
+
+
+ Questions fréquentes
+
+ Vous vous demandez sûrement…
+
+
+
+
+ {FAQS.map(({ q, a }, i) => (
+
+
+ {q}
+
+ +
+
+
+
+ Récupérez vos premières heures dès aujourd'hui.
+
+
+ 30 jours gratuits, puis le plan Free continue avec 5 factures actives. Pas de carte
+ demandée pour démarrer.
+
+
+
+
+
+ Inscription en 30 secondes. Annulation 1-clic à tout moment.
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/sections/Footnotes.tsx b/apps/landing/src/components/sections/Footnotes.tsx
new file mode 100644
index 0000000..2ccc8e0
--- /dev/null
+++ b/apps/landing/src/components/sections/Footnotes.tsx
@@ -0,0 +1,14 @@
+export function Footnotes() {
+ return (
+
+ );
+}
diff --git a/apps/landing/src/components/sections/Gamification.tsx b/apps/landing/src/components/sections/Gamification.tsx
new file mode 100644
index 0000000..c696e44
--- /dev/null
+++ b/apps/landing/src/components/sections/Gamification.tsx
@@ -0,0 +1,46 @@
+import { Eyebrow, Gem } from "@rubis/ui";
+
+export function Gamification() {
+ return (
+
+
+
+
+
+ La devise du temps gagné
+
+
+
+ 1 rubis = 10 minutes de votre vie.
+
+
+ À chaque relance que Rubis envoie à votre place, vous gagnez un rubis. À la fin du
+ mois, vous voyez exactement combien d'heures vous avez récupérées. Pas un graphique
+ de DSO. Pas un PDF abscons. Du temps. Concret.
+
+
+
+
+
+
+ 124
+
+
+ rubis
+
+
+
+ ≈ 24 h 48 de votre mois
+
+
+
+
+ Et oui, on garde un classement amical. Les meilleurs utilisateurs libèrent{" "}
+ 30 heures par mois. Plus de quoi prendre un long
+ week-end. Toutes les 4 semaines.
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/sections/Hero.tsx b/apps/landing/src/components/sections/Hero.tsx
new file mode 100644
index 0000000..a47ccfd
--- /dev/null
+++ b/apps/landing/src/components/sections/Hero.tsx
@@ -0,0 +1,162 @@
+import { Button, Eyebrow, Gem, cn } from "@rubis/ui";
+import { Check } from "lucide-react";
+
+const APP_URL = "https://app.rubis.pro";
+
+/**
+ * Hero principal de la landing.
+ * Mirror direct de l'ancienne section .hero du landing/index.html — même
+ * messages, même hiérarchie typo, même mock card (124 rubis + KPIs +
+ * activité). On reprend les composants @rubis/ui là où ça a du sens.
+ */
+export function Hero() {
+ return (
+
+ {/* Halo rubis discret en haut-droite */}
+
+
+
+ {/* ============ Texte ============ */}
+
+ L'outil de relance pour TPE-PME françaises
+
+
+ Vos factures relancées toutes seules pendant que vous travaillez.
+
+
+
+ L'app de relance de factures impayées pensée pour les TPE-PME françaises.
+ Glissez-déposez vos factures, choisissez un plan de relance, oubliez-les.
+ Rubis envoie, suit, relance — et vous récupérez en moyenne{" "}
+ 5 heures par semaine.
+
+
+
+
+
+
+
+
+
+
+ 30 jours gratuits puis Free 5 factures
+
+
+ Hébergement souverain
+
+ Made in France 🇫🇷
+
+
+
+ {/* ============ Mock card ============ */}
+ {/*
+ Wrapper avec largeur cappée + relatif : le badge flottant se
+ positionne par rapport à la carte, pas à la grille parente.
+ Centrée mobile/tablet, alignée à droite en lg.
+ */}
+
+
+ {/* Hero rubis */}
+
+
+
+
+ 124 rubis
+
+
+ ≈ 24 h 48 que vous n'avez pas passées à relancer.
+
+
+
+
+ {/* KPIs */}
+
+
+
+ Encaissé
+
+
+ 14 320 €
+
+
+ + 2 800 € vs avril
+
+
+
+
+ DSO
+
+
+ 38 j
+
+
+ ↘ −6 j depuis Rubis
+
+
+
+
+ {/* Activity */}
+
+
+ Aujourd'hui
+
+
+
+
+ 📤 Relance envoyée à Atelier Durand
+
+
+
+
+
+ ✓ Facture F-2024-0035 encaissée
+
+
+
+
+ 📥 3 factures importées et OCRisées
+
+
+
+
+
+
+ {/* Badge flottant — relatif au wrapper carte (max-w 480) */}
+
+
+ ~3 minutes le matin
+
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/sections/HowItWorks.tsx b/apps/landing/src/components/sections/HowItWorks.tsx
new file mode 100644
index 0000000..50b5a6d
--- /dev/null
+++ b/apps/landing/src/components/sections/HowItWorks.tsx
@@ -0,0 +1,220 @@
+import { Eyebrow, cn } from "@rubis/ui";
+
+export function HowItWorks() {
+ return (
+
+
+
+ Comment ça marche
+
+ Trois étapes. C'est tout.
+
+
+ Vraiment. Parfois deux, si votre plan par défaut est bien réglé.
+
+
+
+
+
+
+
+
+
+ Nous en avons décliné plusieurs — par exemple, un standard B2B (J+3, J+10, J+20),
+ adapter le ton en fonction de l'échéance et de l'historique du client.
+
+
Et bien sûr, vous pouvez aussi créer les vôtres sur mesure.
+ >
+ }
+ >
+
+
+
+
+
+ Sérieusement. Pendant que vous travaillez, Rubis envoie les emails au moment prévu,
+ suit qui a ouvert, qui n'a pas répondu, et avant chaque relance vous demande
+ discrètement par email : « Cette facture a-t-elle été réglée ? ». Vous répondez en
+ deux secondes. La machine fait le reste.
+
+
+
+ La récompense : votre compteur de rubis grimpe. Tranquillement. Comme une bonne
+ nouvelle régulière.
+
+ La loi est de votre côté. On vous évite juste de la brandir.
+
+
+
+
+ La{" "}
+
+ loi LME
+ {" "}
+ plafonne les délais de paiement entre entreprises à 60 jours{" "}
+ (ou 45 jours fin de mois). Les sanctions peuvent atteindre{" "}
+ 2 millions d'euros. En 2025, le Sénat a voté à
+ l'unanimité un durcissement supplémentaire des règles.
+
+
+ Mais vous n'avez pas envie d'envoyer un commissaire de justice à votre meilleur
+ client. Rubis fait le boulot intermédiaire — relances pro, courtoises, espacées
+ dans le temps. Le ton monte progressivement, jamais d'un coup. Et vous gardez le
+ contrôle total : la mise en demeure, c'est vous qui
+ l'envoyez, sur validation manuelle.
+
+
+
+
+ {/* Clipping presse-style */}
+
+
+
+ );
+}
diff --git a/apps/landing/src/components/sections/Pricing.tsx b/apps/landing/src/components/sections/Pricing.tsx
new file mode 100644
index 0000000..716098f
--- /dev/null
+++ b/apps/landing/src/components/sections/Pricing.tsx
@@ -0,0 +1,164 @@
+import { Button, Eyebrow } from "@rubis/ui";
+import { Check } from "lucide-react";
+
+const APP_URL = "https://app.rubis.pro";
+
+const FEATURES = [
+ ["Factures illimitées.", "Que vous en émettiez 10 ou 500 par mois, c'est le même prix."],
+ ["OCR illimité.", "Drag & drop, photo mobile, batch de 20 d'un coup."],
+ [
+ "Relances signées de votre nom.",
+ "Vos clients voient votre nom et répondent directement à votre email. Aucune mention de Rubis.",
+ ],
+ ["Plans personnalisables", "avec variables et tonalités sur-mesure."],
+ ["Stats détaillées", "+ export CSV pour vos comptables."],
+ ["Support prioritaire.", "Réponse sous 4 h ouvrées, par un humain en France."],
+ ["App mobile et desktop,", "hébergement français."],
+] as const;
+
+export function Pricing() {
+ return (
+
+
+
+ Tarifs
+
+ Moins cher qu'une heure de votre temps mensuel.
+
+
+ On va droit au but. Un plan principal qu'on recommande à 99 % d'entre vous, et deux
+ options autour. C'est tout.
+
+
+
+ {/* Pro plan — anchor */}
+
+
+
+ Le plan qu'on recommande
+
+
+ Le plan Pro.
+
+
+
+ 19 €
+
+
+ par mois
+
+ hors taxes
+
+
+
+ Pour ce prix, vous avez Rubis dans son intégralité.
+ Factures et OCR illimités, plans de relance personnalisés, statistiques détaillées,
+ support prioritaire. Aucun palier caché, aucun surcoût à l'usage.
+
+
+
+
+ Sans engagement, annulable à tout moment
+
+
+
+
+
+
+ Ce qui est inclus
+
+
+ {FEATURES.map(([head, tail]) => (
+
+
+
+ {head} {tail}
+
+
+ ))}
+
+
+
+
+ {/* Asides */}
+
+ — ou —
+
+
+
+
+ Le plan Free fait tourner Rubis sur 5 factures actives en
+ permanence. Gratuit, pour de bon. Notre façon de prouver que la promesse tient.
+
+
+ Tout du Pro, plus : jusqu'à 5 collaborateurs dans la boîte,
+ chacun avec son accès. Vos relances partent de{" "}
+ votre vraie adresse pro (
+
+ compta@votre-entreprise.fr
+
+ ), pas d'une adresse Rubis — vos clients ne se rendent compte de rien et vos emails
+ arrivent mieux en boîte principale.
+
+
+
+
+ Pas de palier caché. Pas de surcoût à l'usage. Annulation en un clic, sans question
+ posée.
+
+ Vous n'avez pas créé votre boîte pour passer vos lundis soirs à rédiger des
+ relances polies. Pendant que vous écrivez "je me permets un petit rappel
+ concernant…", vous ne facturez pas, vous ne vendez pas, vous ne créez pas.
+
+
+ Les PME qui automatisent leurs relances passent de{" "}
+ 8 heures par semaine à{" "}
+ moins de 3. Soit 5 heures de votre vie récupérées.
+ Toutes les semaines. Pour toujours.
+
+
+ );
+}
diff --git a/apps/landing/src/components/sections/Stats.tsx b/apps/landing/src/components/sections/Stats.tsx
new file mode 100644
index 0000000..074c946
--- /dev/null
+++ b/apps/landing/src/components/sections/Stats.tsx
@@ -0,0 +1,55 @@
+import { Eyebrow } from "@rubis/ui";
+
+const STATS = [
+ {
+ num: "44 j",
+ desc: "Le retard moyen de paiement pour une facture émise par une TPE en France.",
+ source: "Source : Altares · Observatoire des délais de paiement, 2024",
+ },
+ {
+ num: "15 Md€",
+ desc: "De trésorerie bloquée chez les PME françaises à cause des retards de paiement.",
+ source: "Source : Banque de France · Rapport annuel 2024",
+ },
+ {
+ num: "−26 %",
+ desc: "De chances d'être payé si vous attendez plus de 30 jours pour relancer.",
+ source: "Source : AFDCC · Étude crédit management",
+ },
+];
+
+export function Stats() {
+ return (
+
+
+
+ L'état des paiements en France
+
+ Trois chiffres qui devraient vous fâcher.
+
+
+ Si vous lisez ça, vous avez probablement une facture impayée à l'heure où on parle.
+ Vous n'êtes pas un cas isolé.
+
+
+
+
+ {STATS.map((s) => (
+
+
+ {s.num}
+
+
{s.desc}
+
+ {s.source}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/landing/src/layouts/Layout.astro b/apps/landing/src/layouts/Layout.astro
new file mode 100644
index 0000000..35c008a
--- /dev/null
+++ b/apps/landing/src/layouts/Layout.astro
@@ -0,0 +1,115 @@
+---
+/**
+ * Layout commun à toutes les pages rubis.pro/*.
+ *
+ * Reçoit en props les meta SEO essentiels. Importe le CSS racine (qui inline
+ * les tokens + base @rubis/ui), monte SiteHeader + SiteFooter, et expose un
+ * pour le contenu de la page.
+ *
+ * Convention : zéro JS hydraté côté public sauf nécessité explicite — le
+ * SiteHeader + SiteFooter sont rendus côté serveur uniquement (pas de
+ * client:load) pour rester sur le bundle minimal.
+ */
+import "../styles/app.css";
+import { SiteHeader } from "../components/SiteHeader";
+import { SiteFooter } from "../components/SiteFooter";
+
+const SITE_URL = "https://rubis.pro";
+
+interface Props {
+ title: string;
+ description: string;
+ /** Path absolu de la page courante (ex. "/blog/foo"). Default = location actuelle. */
+ pathname?: string;
+ /** OG image absolue (1200×630 idéal). */
+ ogImage?: string;
+ /** OG type. Default "website". Mettre "article" sur les pages de blog. */
+ ogType?: "website" | "article";
+ /** Si true → noindex (legal/test). */
+ noindex?: boolean;
+ /** Header solide (bordure + fond) plutôt que transparent (par défaut). */
+ solidHeader?: boolean;
+ /** JSON-LD structured data — passé en string déjà sérialisé OU en object. */
+ jsonLd?: object | object[];
+}
+
+const {
+ title,
+ description,
+ pathname,
+ ogImage,
+ ogType = "website",
+ noindex = false,
+ solidHeader = false,
+ jsonLd,
+} = Astro.props;
+
+const fullTitle = title.includes("Rubis") ? title : `${title} — Rubis sur l'ongle`;
+const url = `${SITE_URL}${pathname ?? Astro.url.pathname}`;
+const robots = noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large";
+const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
+---
+
+
+
+
+
+
+ {fullTitle}
+
+
+
+
+
+
+
+
+
+ {/* Open Graph */}
+
+
+
+
+
+
+ {
+ ogImage && (
+ <>
+
+
+
+
+ >
+ )
+ }
+
+ {/* Twitter Card */}
+
+
+
+ {ogImage && }
+
+ {/* Favicons */}
+
+
+
+
+
+ {/* RSS auto-discovery */}
+
+
+ {/* JSON-LD structured data */}
+ {
+ jsonLdArray.map((data) => (
+
+ ))
+ }
+
+
+
+
+
+
+
+
+
diff --git a/apps/landing/src/layouts/LegalLayout.astro b/apps/landing/src/layouts/LegalLayout.astro
new file mode 100644
index 0000000..a8ee29c
--- /dev/null
+++ b/apps/landing/src/layouts/LegalLayout.astro
@@ -0,0 +1,204 @@
+---
+/**
+ * Layout pour pages légales (mentions, confidentialité, CGV).
+ * Wrappe Layout.astro et fournit un container `prose` cohérent pour
+ * les textes longs : eyebrow + h1 italique + lede + sections h2 ancrées.
+ */
+import Layout from "./Layout.astro";
+
+interface Props {
+ title: string;
+ description: string;
+ /** Eyebrow affiché au-dessus du titre. */
+ eyebrow: string;
+ /** Titre affiché (peut contenir ...). */
+ h1: string;
+ /** Paragraphe d'introduction. */
+ lede: string;
+ /** Date "Dernière mise à jour" — ISO ou littérale. */
+ lastUpdated: string;
+}
+
+const { title, description, eyebrow, h1, lede, lastUpdated } = Astro.props;
+---
+
+
+
+
+
+
+ {eyebrow}
+
+
+
+
+
{lede}
+
Dernière mise à jour : {lastUpdated}
+
+
+
+
+
+
+
+
+
diff --git a/apps/landing/src/lib/api.ts b/apps/landing/src/lib/api.ts
new file mode 100644
index 0000000..3a80c05
--- /dev/null
+++ b/apps/landing/src/lib/api.ts
@@ -0,0 +1,63 @@
+/**
+ * Client API minimaliste pour parler à apps/api en SSR depuis Astro.
+ *
+ * En dev : `API_URL=http://localhost:3333` (cf. .env.development).
+ * En prod : `API_URL=https://app.rubis.pro` injecté par K3s ConfigMap.
+ *
+ * Ces appels sont **server-side only** (pas exposés au client) — donc
+ * pas de problème CORS, on tape directement le service Adonis.
+ */
+
+const API_URL =
+ import.meta.env.API_URL ?? process.env.API_URL ?? "http://localhost:3333";
+
+export type PostSummary = {
+ id: string;
+ slug: string;
+ title: string;
+ excerpt: string;
+ heroImageUrl: string | null;
+ heroImageAlt: string | null;
+ authorName: string;
+ tags: string[];
+ publishedAt: string | null;
+ readingTimeMinutes: number;
+};
+
+export type Post = PostSummary & {
+ contentHtml: string;
+ ogImageUrl: string | null;
+ canonicalUrl: string | null;
+ updatedAt: string | null;
+ wordCount: number;
+ noindex: boolean;
+};
+
+export type PostShowResponse = {
+ post: Post;
+ related: PostSummary[];
+};
+
+/**
+ * Toutes les réponses de l'API Adonis sont enveloppées dans `{ data: ... }`
+ * par le serializer global (cf. apps/api/providers/api_provider.ts). On unwrap
+ * ici une fois pour toutes pour que les consommateurs travaillent directement
+ * sur le payload typé.
+ */
+async function apiGet(path: string): Promise {
+ const res = await fetch(`${API_URL}${path}`, {
+ headers: { Accept: "application/json" },
+ });
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`API ${path} → ${res.status}`);
+ const json = (await res.json()) as { data: T };
+ return json.data;
+}
+
+export async function listPosts(): Promise {
+ return (await apiGet("/api/v1/posts")) ?? [];
+}
+
+export async function getPost(slug: string): Promise {
+ return await apiGet(`/api/v1/posts/${encodeURIComponent(slug)}`);
+}
diff --git a/apps/landing/src/pages/blog/[slug].astro b/apps/landing/src/pages/blog/[slug].astro
new file mode 100644
index 0000000..6dd492b
--- /dev/null
+++ b/apps/landing/src/pages/blog/[slug].astro
@@ -0,0 +1,271 @@
+---
+/**
+ * /blog/:slug — article individuel.
+ *
+ * SSR : fetch le post + articles liés depuis l'API à chaque requête.
+ * 404 si non trouvé. Cache HTTP 5 min + 24h SWR.
+ *
+ * Le contentHtml vient déjà rendu de l'API (marked + sanitize côté
+ * blog_renderer). On l'injecte avec set:html.
+ */
+import Layout from "../../layouts/Layout.astro";
+import { PostCard } from "../../components/blog/PostCard";
+import { getPost } from "../../lib/api";
+
+const { slug } = Astro.params;
+
+const data = slug ? await getPost(slug) : null;
+if (!data) {
+ return new Response(null, { status: 404, statusText: "Not Found" });
+}
+
+const { post, related } = data;
+const url = `https://rubis.pro/blog/${post.slug}`;
+const ogImage = post.ogImageUrl ?? post.heroImageUrl ?? undefined;
+
+const dateFmt = new Intl.DateTimeFormat("fr-FR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+});
+const dateLabel = post.publishedAt ? dateFmt.format(new Date(post.publishedAt)) : null;
+const dateIso = post.publishedAt ?? undefined;
+
+const jsonLd = [
+ {
+ "@context": "https://schema.org",
+ "@type": "Article",
+ headline: post.title,
+ description: post.excerpt,
+ author: { "@type": "Person", name: post.authorName },
+ publisher: {
+ "@type": "Organization",
+ name: "Rubis sur l'ongle",
+ url: "https://rubis.pro",
+ logo: {
+ "@type": "ImageObject",
+ url: "https://rubis.pro/web-app-manifest-512x512.png",
+ },
+ },
+ datePublished: dateIso,
+ dateModified: post.updatedAt ?? dateIso,
+ image: ogImage ? [ogImage] : undefined,
+ inLanguage: "fr-FR",
+ wordCount: post.wordCount,
+ mainEntityOfPage: { "@type": "WebPage", "@id": url },
+ keywords: post.tags.length > 0 ? post.tags.join(", ") : undefined,
+ },
+ {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ { "@type": "ListItem", position: 1, name: "Accueil", item: "https://rubis.pro" },
+ { "@type": "ListItem", position: 2, name: "Blog", item: "https://rubis.pro/blog" },
+ { "@type": "ListItem", position: 3, name: post.title, item: url },
+ ],
+ },
+];
+
+Astro.response.headers.set(
+ "Cache-Control",
+ "public, max-age=300, stale-while-revalidate=86400",
+);
+---
+
+
+
+
+
+
+
+ Article
+
+
+ {post.title}
+
+
+ {dateLabel && }
+ {dateLabel && }
+ {post.readingTimeMinutes} min de lecture
+ {post.authorName && }
+ {post.authorName && par {post.authorName}}
+
+
+ )
+ }
+
+
+
diff --git a/apps/landing/src/pages/blog/index.astro b/apps/landing/src/pages/blog/index.astro
new file mode 100644
index 0000000..385977e
--- /dev/null
+++ b/apps/landing/src/pages/blog/index.astro
@@ -0,0 +1,85 @@
+---
+/**
+ * /blog — liste publique des articles publiés.
+ *
+ * SSR (pas de prerender) : le contenu peut changer à tout moment via
+ * l'admin (PR3) ; on fetch à chaque requête, et on cache HTTP côté
+ * Traefik / nginx pour absorber les pics. Publication = immédiate.
+ */
+import Layout from "../../layouts/Layout.astro";
+import { PostCard } from "../../components/blog/PostCard";
+import { listPosts } from "../../lib/api";
+
+const posts = await listPosts();
+
+const title = "Blog — Le blog des relances qui marchent";
+const description =
+ "Stratégies, modèles d'email et retours du terrain pour récupérer vos factures impayées sans abîmer la relation client. Sans bullshit, écrit pour les TPE-PME françaises.";
+
+const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "Blog",
+ name: "Blog Rubis sur l'ongle",
+ description,
+ url: "https://rubis.pro/blog",
+ publisher: {
+ "@type": "Organization",
+ name: "Rubis sur l'ongle",
+ url: "https://rubis.pro",
+ logo: {
+ "@type": "ImageObject",
+ url: "https://rubis.pro/web-app-manifest-512x512.png",
+ },
+ },
+ blogPost: posts.map((p) => ({
+ "@type": "BlogPosting",
+ headline: p.title,
+ description: p.excerpt,
+ url: `https://rubis.pro/blog/${p.slug}`,
+ datePublished: p.publishedAt,
+ })),
+};
+
+// Cache HTTP : 5 min frais + 24h SWR. L'admin "Publier" invalide via webhook (PR3).
+Astro.response.headers.set(
+ "Cache-Control",
+ "public, max-age=300, stale-while-revalidate=86400",
+);
+---
+
+
+
+
+
+
+ Blog Rubis
+
+
+ Le blog des relances qui marchent
+
+
+ Stratégies, modèles d'email et retours du terrain pour récupérer vos factures
+ impayées sans abîmer la relation client. Sans bullshit, écrit pour les TPE-PME
+ françaises.
+
+
+
+
+
+ {
+ posts.length === 0 ? (
+
+
Aucun article publié pour l'instant. Reviens vite.
+
+ ) : (
+
+ {posts.map((post) => )}
+
+ )
+ }
+
+
diff --git a/apps/landing/src/pages/blog/rss.xml.ts b/apps/landing/src/pages/blog/rss.xml.ts
new file mode 100644
index 0000000..7d1a944
--- /dev/null
+++ b/apps/landing/src/pages/blog/rss.xml.ts
@@ -0,0 +1,63 @@
+import type { APIRoute } from "astro";
+
+import { listPosts } from "../../lib/api";
+
+const SITE = "https://rubis.pro";
+
+function escapeXml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function toRfc2822(iso: string | null): string {
+ if (!iso) return "";
+ return new Date(iso).toUTCString();
+}
+
+/**
+ * Flux RSS 2.0 — 20 derniers articles publiés. Auto-discovered depuis
+ * Layout.astro via .
+ */
+export const GET: APIRoute = async () => {
+ const posts = (await listPosts()).slice(0, 20);
+ const lastBuild = posts[0]?.publishedAt ? toRfc2822(posts[0].publishedAt) : new Date().toUTCString();
+
+ const items = posts
+ .map((p) => {
+ const url = `${SITE}/blog/${p.slug}`;
+ return `
+ ${escapeXml(p.title)}
+ ${escapeXml(url)}
+ ${escapeXml(url)}
+ ${toRfc2822(p.publishedAt)}
+ ${escapeXml(p.excerpt)}
+ contact@rubis.pro (${escapeXml(p.authorName)})
+ `;
+ })
+ .join("\n");
+
+ const xml = `
+
+
+ Rubis sur l'ongle — Blog
+ ${SITE}/blog
+
+ Stratégies, modèles d'email et retours du terrain pour relancer vos factures impayées sans abîmer la relation client.
+ fr-FR
+ ${lastBuild}
+${items}
+
+`;
+
+ return new Response(xml, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/xml; charset=utf-8",
+ "Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
+ },
+ });
+};
diff --git a/apps/landing/src/pages/cgv.astro b/apps/landing/src/pages/cgv.astro
new file mode 100644
index 0000000..ebdf6c1
--- /dev/null
+++ b/apps/landing/src/pages/cgv.astro
@@ -0,0 +1,165 @@
+---
+export const prerender = true;
+
+import LegalLayout from "../layouts/LegalLayout.astro";
+---
+
+de Vente`}
+ lede="Ces conditions encadrent l'utilisation du service Rubis sur l'ongle entre l'éditeur (Arthur Barré, entrepreneur individuel) et l'utilisateur professionnel (TPE, PME, freelance, ou personne morale française) qui souscrit à un plan payant ou utilise la version gratuite."
+ lastUpdated="7 mai 2026"
+>
+
Éditeur : Arthur Barré, entrepreneur individuel, responsable de la publication et de l'édition du service Rubis sur l'ongle (cf. mentions légales).
+
Utilisateur : toute personne physique ou morale exerçant à titre professionnel qui crée un compte et utilise le service.
+
Service : la plateforme SaaS « Rubis sur l'ongle » accessible depuis app.rubis.pro, permettant d'automatiser la relance de factures impayées.
+
Compte : espace utilisateur protégé par un identifiant et un mot de passe permettant d'accéder au service.
+
Plan : formule d'abonnement choisie par l'utilisateur (Free, Pro ou Business) déterminant le périmètre des fonctionnalités et le tarif.
+
Client final : tiers à qui l'utilisateur facture des prestations et à qui les emails de relance sont adressés.
+
+
+
2. Objet
+
Les présentes conditions ont pour objet de définir les modalités contractuelles applicables à l'utilisation du service Rubis sur l'ongle, qui permet à l'utilisateur de :
+
+
Importer des factures (PDF, image, ou saisie manuelle) ;
+
Configurer des plans de relance avec emails programmés ;
+
Envoyer automatiquement ces relances à ses clients finaux selon la cadence choisie ;
+
Suivre le statut des factures, valider les paiements, gérer les mises en demeure.
+
+
+
3. Acceptation des CGV
+
L'inscription au service et la création d'un compte impliquent l'acceptation pleine et entière des présentes CGV par l'utilisateur, ainsi que de la politique de confidentialité.
+
L'utilisateur déclare agir dans le cadre d'une activité professionnelle. Le service n'est pas destiné aux consommateurs au sens du Code de la consommation.
+
+
4. Création du compte
+
L'inscription nécessite la fourniture d'une adresse email valide et d'un mot de passe (ou d'une connexion via un fournisseur d'identité tiers — Google, Microsoft). L'utilisateur s'engage à fournir des informations exactes et à les tenir à jour.
+
L'utilisateur est seul responsable de la confidentialité de ses identifiants et de toutes les actions effectuées depuis son compte. En cas de soupçon de compromission, il doit immédiatement réinitialiser son mot de passe et nous prévenir à contact@rubis.pro.
+
+
5. Description du service
+
Le service comprend, selon le plan souscrit, les fonctionnalités suivantes :
+
+
+
Plan
Périmètre principal
+
+
+
Free
Jusqu'à 5 factures actives en relance simultanément. Plans de relance standards. OCR limité. Un seul utilisateur.
+
Pro
Factures et OCR illimités, plans personnalisables, statistiques détaillées, relances signées au nom de l'utilisateur (sous-domaine Rubis), support prioritaire. Un seul utilisateur.
+
Business
Tout du Pro, plus jusqu'à 5 collaborateurs, envoi des relances depuis le domaine propre de l'utilisateur (configuration DKIM/SPF), onboarding personnel.
+
+
+
La liste détaillée et à jour des fonctionnalités est disponible sur la page tarifs du site.
+
+
6. Tarifs, essai et facturation
+
+
6.1 Tarifs
+
Les tarifs en vigueur sont indiqués sur la page tarifs du site, en euros et hors taxes. La TVA, lorsque applicable, est ajoutée selon le taux en vigueur (20 % en France métropolitaine).
+
+
6.2 Période d'essai gratuite
+
Tout nouvel utilisateur bénéficie d'une période d'essai de 30 jours permettant l'accès au plan Pro sans engagement et sans carte bancaire requise. À l'issue de cette période, l'utilisateur peut souscrire à un plan payant ou poursuivre gratuitement avec le plan Free (5 factures actives).
+
+
6.3 Modalités de paiement
+
Le paiement des plans payants s'effectue en ligne via notre prestataire de paiement Stripe, par carte bancaire ou prélèvement SEPA. Aucune donnée bancaire n'est stockée sur les serveurs de l'éditeur.
+
Le règlement est effectué d'avance, par mensualités successives, à la date anniversaire de la souscription.
+
+
6.4 Facturation
+
Une facture est émise automatiquement à chaque échéance et mise à disposition dans l'espace utilisateur (rubrique « Paramètres › Facturation »). Elle est également envoyée par email à l'adresse de contact renseignée.
+
+
6.5 Défaut de paiement
+
En cas de défaut de paiement (rejet bancaire, expiration de la carte, etc.), une relance automatique est envoyée à l'utilisateur. À défaut de régularisation sous 14 jours, l'éditeur se réserve le droit de suspendre l'accès aux fonctionnalités payantes du compte. Les données de l'utilisateur restent conservées pendant cette période, et l'accès est rétabli dès la régularisation.
+
+
6.6 Évolution tarifaire
+
L'éditeur se réserve le droit de modifier ses tarifs. Toute évolution est notifiée par email à l'utilisateur au moins 30 jours avant son entrée en vigueur. L'utilisateur peut alors résilier son abonnement avant l'application du nouveau tarif sans pénalité.
+
+
7. Durée et résiliation
+
+
7.1 Durée
+
L'abonnement est conclu pour une durée d'un mois et se renouvelle tacitement à chaque échéance, sauf résiliation par l'une des parties.
+
+
7.2 Résiliation par l'utilisateur
+
L'utilisateur peut résilier son abonnement à tout moment, en un clic, depuis son espace personnel (« Paramètres › Facturation › Annuler l'abonnement »). La résiliation prend effet à la fin de la période en cours (déjà payée). Aucun remboursement prorata temporis n'est dû.
+
La suppression complète du compte (et de toutes les données associées) peut être demandée par email à contact@rubis.pro ou via le bouton dédié dans les paramètres. Voir la politique de confidentialité pour les durées de conservation post-suppression.
+
+
7.3 Résiliation par l'éditeur
+
L'éditeur peut résilier le compte de l'utilisateur, sans préavis ni indemnité, dans les cas suivants :
+
+
Défaut de paiement persistant (au-delà de 30 jours) ;
+
Violation manifeste des présentes CGV ou de la législation en vigueur ;
+
Utilisation du service à des fins illicites, frauduleuses, de harcèlement ou de spam ;
+
Atteinte à la sécurité ou à l'intégrité du service.
+
+
+
8. Disponibilité du service
+
L'éditeur met en œuvre les moyens raisonnables pour assurer la disponibilité du service 24h/24 et 7j/7. Aucun engagement de disponibilité chiffré (SLA) n'est contractuellement garanti dans cette version. Des interruptions peuvent survenir pour des opérations de maintenance, des mises à jour, ou des causes indépendantes de la volonté de l'éditeur.
+
Les opérations de maintenance planifiée sont annoncées à l'avance par email ou notification dans l'application lorsque possible.
+
+
9. Obligations de l'utilisateur
+
L'utilisateur s'engage à :
+
+
Utiliser le service conformément à sa destination, à la législation française et aux usages commerciaux loyaux ;
+
Respecter le cadre légal applicable aux relances de factures, notamment la loi LME sur les délais de paiement et les règles de procédure civile concernant la mise en demeure ;
+
S'assurer du caractère professionnel et licite de ses relations commerciales avec ses clients finaux ;
+
Ne pas utiliser le service pour adresser des communications non sollicitées (spam) à des destinataires sans relation commerciale préexistante ;
+
Disposer du droit d'utilisation des données client (email, nom, etc.) qu'il renseigne dans le service ;
+
Ne pas tenter de contourner les limitations techniques du service ou d'extraire massivement des données via des moyens automatisés non documentés.
+
+
La mise en demeure est traitée par le service comme une étape de plan particulière nécessitant une validation manuelle de l'utilisateur avant envoi. L'éditeur ne saurait être tenu responsable du contenu ou de la qualification juridique de cette mise en demeure.
+
+
10. Propriété intellectuelle
+
La marque « Rubis sur l'ongle », le logo (◆), les textes, le code source, les chartes graphiques et l'ensemble des éléments composant le service sont la propriété exclusive de l'éditeur ou de ses partenaires.
+
L'utilisateur conserve l'intégralité de la propriété sur les données qu'il importe ou crée dans le service (factures, plans de relance, templates d'emails personnalisés, données clients). L'éditeur ne dispose que d'un droit d'usage strictement limité à la fourniture du service.
+
+
11. Données personnelles
+
Le traitement des données personnelles par l'éditeur est régi par notre politique de confidentialité, qui fait partie intégrante des présentes CGV.
+
Pour les données des clients finaux de l'utilisateur (emails, noms, etc.), l'éditeur agit en tant que sous-traitant au sens de l'article 28 du RGPD. Un avenant DPA (Data Processing Agreement) peut être fourni sur demande à contact@rubis.pro.
+
+
12. Responsabilité
+
L'éditeur fournit un outil d'assistance à l'envoi de relances automatisées. L'utilisateur reste seul responsable :
+
+
Du contenu des emails envoyés à ses clients ;
+
De la légalité et de la qualité de la relation commerciale qui motive la relance ;
+
De la qualification juridique éventuelle d'une mise en demeure ;
+
De l'exactitude des données saisies dans le service.
+
+
La responsabilité de l'éditeur est strictement limitée aux dommages directs et prévisibles, liés à un défaut prouvé du service. Les dommages indirects (notamment : perte de données, perte de chiffre d'affaires, perte de clientèle, atteinte à l'image) sont exclus dans toute la mesure permise par la loi.
+
En tout état de cause, et conformément aux usages commerciaux entre professionnels, la responsabilité totale de l'éditeur est plafonnée au montant des sommes effectivement versées par l'utilisateur au titre des 12 mois précédant le fait générateur de la responsabilité.
+
+
13. Force majeure
+
L'éditeur ne saurait être tenu responsable d'un manquement à ses obligations en cas de survenance d'un événement de force majeure au sens de l'article 1218 du Code civil (catastrophe naturelle, attaque informatique majeure, panne réseau d'envergure, décision gouvernementale, etc.).
+
+
14. Modification des CGV
+
L'éditeur peut modifier les présentes CGV pour refléter des évolutions du service, de la législation ou des conditions commerciales. Toute modification substantielle est notifiée à l'utilisateur par email au moins 30 jours avant son entrée en vigueur.
+
La poursuite de l'utilisation du service au-delà de cette période vaut acceptation des nouvelles CGV. À défaut, l'utilisateur peut résilier son abonnement sans pénalité avant l'entrée en vigueur des nouvelles conditions.
+
+
15. Cession
+
L'éditeur peut librement céder les présentes CGV ainsi que les contrats en cours dans le cadre d'une opération de restructuration, de fusion ou de cession d'activité. L'utilisateur en sera informé par email.
+
+
16. Droit applicable et juridiction
+
Les présentes CGV sont régies par le droit français. Tout litige relatif à leur formation, exécution ou interprétation, qui n'aurait pu être réglé à l'amiable, relèvera de la compétence exclusive des tribunaux français du siège de l'éditeur, y compris en cas de pluralité de défendeurs ou d'appel en garantie, et nonobstant toute clause contraire éventuelle.
+
Avant toute action contentieuse, les parties s'engagent à rechercher une solution amiable, par échange écrit à contact@rubis.pro, dans un délai raisonnable.
+
diff --git a/apps/landing/src/pages/confidentialite.astro b/apps/landing/src/pages/confidentialite.astro
new file mode 100644
index 0000000..845de56
--- /dev/null
+++ b/apps/landing/src/pages/confidentialite.astro
@@ -0,0 +1,150 @@
+---
+export const prerender = true;
+
+import LegalLayout from "../layouts/LegalLayout.astro";
+---
+
+confidentialité`}
+ lede="Cette page décrit comment Rubis sur l'ongle collecte, utilise et protège vos données personnelles, conformément au Règlement (UE) 2016/679 (RGPD) et à la loi Informatique et Libertés modifiée."
+ lastUpdated="7 mai 2026"
+>
+
+ Nous collectons uniquement les données strictement nécessaires au fonctionnement du service. Aucune donnée n'est revendue à des tiers à des fins commerciales.
+
+
+
2.1 Données d'inscription et de compte
+
+
Email (identifiant de connexion + canal de notification check-in)
+
Mot de passe (stocké de manière chiffrée et irréversible)
+
Nom complet (signature des relances)
+
Nom de l'organisation et SIRET (optionnel, pour les mises en demeure formelles)
Clients du user : nom, email, téléphone, adresse, SIRET (si fourni)
+
Plans de relance et templates d'emails que l'utilisateur configure
+
+
+ Important : les données des clients finaux du user sont collectées par l'utilisateur lui-même qui en est responsable de traitement vis-à-vis de ses clients. Rubis agit en tant que sous-traitant au sens de l'article 28 du RGPD pour ces données. Un avenant DPA peut être fourni sur demande.
+
+
+
2.3 Données techniques et journaux
+
+
Journaux de connexion (adresse IP, date) — conservés à des fins de sécurité
+
Cookie de session pour maintenir la connexion
+
Identifiants techniques de notre prestataire de paiement (en cas d'abonnement payant)
+
+
+
3. Finalités et bases légales
+
+
+
+
Finalité
+
Base légale
+
+
+
+
Fourniture du service (relances, OCR, dashboard)
Exécution du contrat (art. 6.1.b RGPD)
+
Facturation et abonnement
Exécution du contrat + obligation légale (comptable)
+
Sécurité du service (anti-fraude, logs)
Intérêt légitime (art. 6.1.f)
+
Support utilisateur
Exécution du contrat
+
Statistiques d'usage anonymisées
Intérêt légitime — pas de profiling individuel
+
Communication commerciale
Consentement explicite (opt-in à l'inscription)
+
+
+
+
4. Sous-traitants et hébergement
+
+ L'application, la base de données et les fichiers utilisateurs sont hébergés en France. Trois sous-traitants spécialisés interviennent pour des fonctions ciblées :
+
+
+
+
+
Sous-traitant
+
Rôle & localisation
+
+
+
+
Stripe
Traitement des paiements. Hébergement : Union européenne.
+
Resend
Envoi des emails transactionnels et de relance. Hébergement : Union européenne (clauses contractuelles types pour les éventuels traitements hors UE).
+
Mistral AI
Reconnaissance du texte sur les factures importées. Hébergement : France.
+
+
+
Aucun transfert de données hors Union européenne n'a lieu sans encadrement contractuel approprié.
+
+
5. Durée de conservation
+
+
Compte utilisateur actif : conservation tant que le compte est ouvert.
+
Compte supprimé : suppression complète des données dans un délai de 30 jours, hors obligations comptables.
+
Factures émises et payées : conservation 10 ans (obligation comptable, art. L.123-22 du Code de commerce).
+
Logs techniques : 30 jours.
+
Données Stripe (factures d'abonnement) : selon politique de Stripe (en général 10 ans).
+
+
+
6. Vos droits
+
Conformément au RGPD, vous disposez à tout moment des droits suivants :
+
+
Droit d'accès à vos données personnelles
+
Droit de rectification en cas de données inexactes
+
Droit à l'effacement (« droit à l'oubli »)
+
Droit à la portabilité dans un format lisible (export JSON sur demande)
+
Droit à la limitation du traitement
+
Droit d'opposition au traitement pour motif légitime
+
Droit de retirer votre consentement à tout moment, sans rétroactivité
+
+
+ Pour exercer ces droits, écrivez-nous à contact@rubis.pro en précisant l'email associé à votre compte. Nous répondons dans un délai maximal d'un mois (extensible à trois mois pour les demandes complexes, avec information dans le mois initial).
+
+
+
7. Cookies
+
Nous utilisons exclusivement des cookies strictement nécessaires au fonctionnement du service. Aucun cookie publicitaire ni de mesure d'audience tierce n'est déposé.
+
Un cookie de session permet de maintenir la connexion à votre compte. Aucun consentement préalable n'est requis pour ces cookies fonctionnels (article 82 de la loi Informatique et Libertés).
+
+
8. Sécurité
+
Nous mettons en œuvre les mesures techniques et organisationnelles appropriées pour préserver la confidentialité, l'intégrité et la disponibilité de vos données : chiffrement des échanges, contrôle des accès, sauvegardes régulières et journalisation des opérations sensibles.
Nous pouvons modifier cette politique pour refléter des évolutions légales, techniques ou organisationnelles. Toute modification substantielle sera annoncée par email aux utilisateurs concernés au moins 30 jours avant son entrée en vigueur.
+
diff --git a/apps/landing/src/pages/index.astro b/apps/landing/src/pages/index.astro
new file mode 100644
index 0000000..631450e
--- /dev/null
+++ b/apps/landing/src/pages/index.astro
@@ -0,0 +1,57 @@
+---
+/**
+ * / — landing publique de rubis.pro.
+ *
+ * Statique au build (`prerender = true`) : HTML figé, performances LCP/CLS
+ * optimales. Toute mise à jour de copy passe par un re-déploiement (acceptable
+ * vu la fréquence de modif d'une landing). Les sections internes sont des
+ * composants React .tsx — ce fichier .astro n'est qu'un wrapper de page.
+ */
+export const prerender = true;
+
+import Layout from "../layouts/Layout.astro";
+import { Hero } from "../components/sections/Hero";
+import { Stats } from "../components/sections/Stats";
+import { Promise as PromiseSection } from "../components/sections/Promise";
+import { HowItWorks } from "../components/sections/HowItWorks";
+import { Gamification } from "../components/sections/Gamification";
+import { Legal } from "../components/sections/Legal";
+import { Pricing } from "../components/sections/Pricing";
+import { FAQ } from "../components/sections/FAQ";
+import { FinalCTA } from "../components/sections/FinalCTA";
+import { Footnotes } from "../components/sections/Footnotes";
+
+const title = "Vos factures relancées toutes seules pendant que vous travaillez";
+const description =
+ "Le SaaS de relance de factures impayées pour TPE-PME françaises. Drag-and-drop, OCR, plans de relance automatiques. 30 jours gratuits, sans carte bancaire.";
+
+const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "SoftwareApplication",
+ name: "Rubis sur l'ongle",
+ description,
+ url: "https://rubis.pro",
+ applicationCategory: "BusinessApplication",
+ operatingSystem: "Web",
+ offers: [
+ { "@type": "Offer", name: "Free", price: "0", priceCurrency: "EUR" },
+ { "@type": "Offer", name: "Pro", price: "19", priceCurrency: "EUR" },
+ { "@type": "Offer", name: "Business", price: "49", priceCurrency: "EUR" },
+ ],
+ inLanguage: "fr-FR",
+ publisher: { "@type": "Organization", name: "Rubis sur l'ongle", url: "https://rubis.pro" },
+};
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/landing/src/pages/mentions-legales.astro b/apps/landing/src/pages/mentions-legales.astro
new file mode 100644
index 0000000..35dabba
--- /dev/null
+++ b/apps/landing/src/pages/mentions-legales.astro
@@ -0,0 +1,94 @@
+---
+export const prerender = true;
+
+import LegalLayout from "../layouts/LegalLayout.astro";
+---
+
+légales`}
+ lede="Conformément aux articles 6-III et 19 de la Loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique (LCEN), voici les informations relatives à l'éditeur, à l'hébergeur et aux conditions d'utilisation du site."
+ lastUpdated="7 mai 2026"
+>
+
+ Pour les demandes liées aux données personnelles ou pour exercer vos droits RGPD, consultez notre politique de confidentialité.
+
+
+
5. Propriété intellectuelle
+
+ L'ensemble des contenus présents sur le site et l'application — y compris les textes, la marque "Rubis sur l'ongle", le logo (◆), les illustrations, la palette graphique, le code source des templates et de l'application — sont la propriété exclusive d'Arthur Barré ou font l'objet d'une autorisation d'utilisation.
+
+
+ Toute reproduction, représentation, modification, publication ou adaptation totale ou partielle des éléments du site, quel que soit le moyen ou le procédé utilisé, est interdite sans autorisation écrite préalable.
+
+
+
6. Limitation de responsabilité
+
+ L'éditeur s'efforce d'assurer au mieux l'exactitude et la mise à jour des informations présentées. Cependant, il ne peut garantir l'absence d'erreurs ou d'omissions et décline toute responsabilité quant à l'usage qui pourrait être fait du contenu.
+
+
+ Concernant l'application Rubis, l'éditeur met à disposition un outil d'assistance à la relance automatisée. L'utilisateur reste seul responsable du contenu des relances envoyées à ses clients, du respect de la législation applicable (notamment la LME pour les délais de paiement) et de la qualification éventuelle d'une mise en demeure.
+
+
+
7. Liens externes
+
+ Le site peut contenir des liens vers des sites tiers (notamment les pages réglementaires de Stripe, Resend ou la CNIL). L'éditeur n'exerce aucun contrôle sur ces sites et décline toute responsabilité quant à leur contenu ou aux pratiques de confidentialité qui leur sont propres.
+
+
+
8. Cookies
+
+ Le site landing rubis.pro ne dépose aucun cookie de mesure d'audience ni de traçage publicitaire. L'application app.rubis.pro utilise uniquement des cookies strictement nécessaires au fonctionnement (session d'authentification, refresh tokens). Les détails sont décrits dans la politique de confidentialité.
+
+
+
9. Droit applicable et juridiction
+
+ Les présentes mentions légales sont régies par le droit français. En cas de litige ou de désaccord, et après tentative de recherche d'une solution amiable, compétence est attribuée aux tribunaux français compétents, conformément aux règles de procédure en vigueur.
+
+
diff --git a/apps/landing/src/pages/robots.txt.ts b/apps/landing/src/pages/robots.txt.ts
new file mode 100644
index 0000000..87b5cde
--- /dev/null
+++ b/apps/landing/src/pages/robots.txt.ts
@@ -0,0 +1,19 @@
+import type { APIRoute } from "astro";
+
+const SITE = "https://rubis.pro";
+
+export const GET: APIRoute = () => {
+ const txt = `User-agent: *
+Allow: /
+
+Sitemap: ${SITE}/sitemap.xml
+`;
+
+ return new Response(txt, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+};
diff --git a/apps/landing/src/pages/sitemap.xml.ts b/apps/landing/src/pages/sitemap.xml.ts
new file mode 100644
index 0000000..2400618
--- /dev/null
+++ b/apps/landing/src/pages/sitemap.xml.ts
@@ -0,0 +1,54 @@
+import type { APIRoute } from "astro";
+
+import { listPosts } from "../lib/api";
+
+const SITE = "https://rubis.pro";
+
+const STATIC_PAGES: Array<{ path: string; priority: string; changefreq: string }> = [
+ { path: "/", priority: "1.0", changefreq: "weekly" },
+ { path: "/blog", priority: "0.9", changefreq: "weekly" },
+ { path: "/mentions-legales", priority: "0.3", changefreq: "yearly" },
+ { path: "/confidentialite", priority: "0.3", changefreq: "yearly" },
+ { path: "/cgv", priority: "0.3", changefreq: "yearly" },
+];
+
+/**
+ * Sitemap unifié rubis.pro — pages statiques + tous les articles publiés
+ * (hors noindex). Régénéré à chaque requête (cheap : O(N) sur la liste posts).
+ */
+export const GET: APIRoute = async () => {
+ const posts = await listPosts();
+
+ const staticUrls = STATIC_PAGES.map(
+ ({ path, priority, changefreq }) =>
+ `
+ ${SITE}${path}
+ ${changefreq}
+ ${priority}
+ `,
+ ).join("\n");
+
+ const postUrls = posts
+ .map(
+ (p) => `
+ ${SITE}/blog/${p.slug}${p.publishedAt ? `\n ${p.publishedAt}` : ""}
+ monthly
+ 0.7
+ `,
+ )
+ .join("\n");
+
+ const xml = `
+
+${staticUrls}
+${postUrls}
+`;
+
+ return new Response(xml, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/xml; charset=utf-8",
+ "Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
+ },
+ });
+};
diff --git a/apps/landing/src/styles/app.css b/apps/landing/src/styles/app.css
new file mode 100644
index 0000000..11f744a
--- /dev/null
+++ b/apps/landing/src/styles/app.css
@@ -0,0 +1,11 @@
+@import "tailwindcss";
+
+@import "@fontsource-variable/bricolage-grotesque";
+@import "@fontsource-variable/inter";
+
+/* Design tokens + base layer + utilities — source unique dans @rubis/ui. */
+@import "@rubis/ui/styles/tokens.css";
+@import "@rubis/ui/styles/base.css";
+
+/* Tailwind v4 doit scanner les composants partagés pour générer leurs classes. */
+@source "../../../../packages/ui/src/**/*.{ts,tsx}";
diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json
new file mode 100644
index 0000000..e7810f9
--- /dev/null
+++ b/apps/landing/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "react",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index a687277..6cc68d6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -27,6 +27,7 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@rubis/shared": "workspace:*",
+ "@rubis/ui": "workspace:*",
"@sentry/react": "^10.52.0",
"@tanstack/react-form": "^1.0.0",
"@tanstack/react-query": "^5.66.0",
diff --git a/apps/web/src/components/checkin/InAppCheckinModal.tsx b/apps/web/src/components/checkin/InAppCheckinModal.tsx
index 139d6a2..f71eb2a 100644
--- a/apps/web/src/components/checkin/InAppCheckinModal.tsx
+++ b/apps/web/src/components/checkin/InAppCheckinModal.tsx
@@ -19,7 +19,7 @@ import {
import { formatDate, formatDueDelta, formatEuros, isOverdue } from "@/lib/format";
import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
/**
* Modale qui se déclenche quand l'org a des factures en
diff --git a/apps/web/src/components/clients/ClientCreateDialog.tsx b/apps/web/src/components/clients/ClientCreateDialog.tsx
index f363ba3..4615a47 100644
--- a/apps/web/src/components/clients/ClientCreateDialog.tsx
+++ b/apps/web/src/components/clients/ClientCreateDialog.tsx
@@ -8,7 +8,7 @@ import type { Client } from "@rubis/shared";
import { api, ApiError } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import {
Dialog,
DialogClose,
diff --git a/apps/web/src/components/dashboard/ActivityFeed.tsx b/apps/web/src/components/dashboard/ActivityFeed.tsx
index e61cb4a..91961e7 100644
--- a/apps/web/src/components/dashboard/ActivityFeed.tsx
+++ b/apps/web/src/components/dashboard/ActivityFeed.tsx
@@ -2,8 +2,8 @@ import { format, parseISO } from "date-fns";
import { fr } from "date-fns/locale";
import { Send, CheckCircle2, Inbox, AlertTriangle, type LucideIcon } from "lucide-react";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { cn } from "@/lib/utils";
/**
diff --git a/apps/web/src/components/dashboard/KpiCard.tsx b/apps/web/src/components/dashboard/KpiCard.tsx
index cbdd20c..321db98 100644
--- a/apps/web/src/components/dashboard/KpiCard.tsx
+++ b/apps/web/src/components/dashboard/KpiCard.tsx
@@ -1,4 +1,4 @@
-import { Card } from "@/components/ui/Card";
+import { Card } from "@rubis/ui";
import { cn } from "@/lib/utils";
/**
diff --git a/apps/web/src/components/dashboard/RubisHero.tsx b/apps/web/src/components/dashboard/RubisHero.tsx
index 485adf0..dfb9c0d 100644
--- a/apps/web/src/components/dashboard/RubisHero.tsx
+++ b/apps/web/src/components/dashboard/RubisHero.tsx
@@ -1,7 +1,6 @@
-import { Card } from "@/components/ui/Card";
+import { Brand, Card } from "@rubis/ui";
import { formatRubisToHours } from "@/lib/format";
import { cn } from "@/lib/utils";
-import { Brand } from "../brand/Brand";
/**
* RubisHero — la pièce centrale du dashboard.
diff --git a/apps/web/src/components/dashboard/TopLatePayers.tsx b/apps/web/src/components/dashboard/TopLatePayers.tsx
index 7b0ec4e..d41dda9 100644
--- a/apps/web/src/components/dashboard/TopLatePayers.tsx
+++ b/apps/web/src/components/dashboard/TopLatePayers.tsx
@@ -1,7 +1,7 @@
import { Link } from "@tanstack/react-router";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { cn } from "@/lib/utils";
/**
diff --git a/apps/web/src/components/demo/DemoClock.tsx b/apps/web/src/components/demo/DemoClock.tsx
index 2d2d654..87ce1f4 100644
--- a/apps/web/src/components/demo/DemoClock.tsx
+++ b/apps/web/src/components/demo/DemoClock.tsx
@@ -9,7 +9,7 @@ import {
useDemoState,
useDemoTick,
} from "@/lib/demo";
-import { Gem } from "@/components/brand/Gem";
+import { Gem } from "@rubis/ui";
import { DemoEmailSlide } from "./DemoEmailSlide";
/**
diff --git a/apps/web/src/components/demo/DemoEmailSlide.tsx b/apps/web/src/components/demo/DemoEmailSlide.tsx
index b393ae9..7fb2f39 100644
--- a/apps/web/src/components/demo/DemoEmailSlide.tsx
+++ b/apps/web/src/components/demo/DemoEmailSlide.tsx
@@ -10,7 +10,7 @@ import { queryKeysDemo } from "@/lib/demo";
import { cn } from "@/lib/utils";
import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
import type { DemoCapturedEmail, FiredEvent } from "@/lib/demo";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { StatusBadge } from "@/components/ui/StatusBadge";
/**
diff --git a/apps/web/src/components/demo/DemoToggle.tsx b/apps/web/src/components/demo/DemoToggle.tsx
index 8d5a131..4b0d7d4 100644
--- a/apps/web/src/components/demo/DemoToggle.tsx
+++ b/apps/web/src/components/demo/DemoToggle.tsx
@@ -3,8 +3,8 @@ import { Sparkles, Power } from "lucide-react";
import { toast } from "sonner";
import { useDemoEnd, useDemoStart, useDemoState } from "@/lib/demo";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
/**
* Bouton "Mode démo" pour /parametres.
diff --git a/apps/web/src/components/factures/Dropzone.tsx b/apps/web/src/components/factures/Dropzone.tsx
index d841c29..4260783 100644
--- a/apps/web/src/components/factures/Dropzone.tsx
+++ b/apps/web/src/components/factures/Dropzone.tsx
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback } from "react";
import { UploadCloud, FilePlus, FolderOpen } from "lucide-react";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { ACCEPTED_INVOICE_MIME_TYPES, MAX_INVOICE_FILE_SIZE_BYTES } from "@rubis/shared";
import { cn } from "@/lib/utils";
diff --git a/apps/web/src/components/factures/FilterChips.tsx b/apps/web/src/components/factures/FilterChips.tsx
index e6a89d5..64aa666 100644
--- a/apps/web/src/components/factures/FilterChips.tsx
+++ b/apps/web/src/components/factures/FilterChips.tsx
@@ -1,4 +1,4 @@
-import { Chip } from "@/components/ui/Chip";
+import { Chip } from "@rubis/ui";
import { cn } from "@/lib/utils";
/**
diff --git a/apps/web/src/components/factures/ManualInvoiceDialog.tsx b/apps/web/src/components/factures/ManualInvoiceDialog.tsx
index f8e7caf..98a8ca7 100644
--- a/apps/web/src/components/factures/ManualInvoiceDialog.tsx
+++ b/apps/web/src/components/factures/ManualInvoiceDialog.tsx
@@ -8,7 +8,7 @@ import { z } from "zod";
import type { Plan } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import {
Dialog,
DialogClose,
diff --git a/apps/web/src/components/layout/AppLayout.tsx b/apps/web/src/components/layout/AppLayout.tsx
index 1b2dfce..5f42549 100644
--- a/apps/web/src/components/layout/AppLayout.tsx
+++ b/apps/web/src/components/layout/AppLayout.tsx
@@ -3,7 +3,7 @@ import { Plus, Upload } from "lucide-react";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { AppSidebar } from "./AppSidebar";
import { AppTopbar } from "./AppTopbar";
import { MobileTabBar } from "./MobileTabBar";
diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx
index 007ca44..9734947 100644
--- a/apps/web/src/components/layout/AppSidebar.tsx
+++ b/apps/web/src/components/layout/AppSidebar.tsx
@@ -12,8 +12,8 @@ import {
TrendingUp,
} from "lucide-react";
-import { Brand } from "@/components/brand/Brand";
-import { Gem } from "@/components/brand/Gem";
+import { Brand } from "@rubis/ui";
+import { Gem } from "@rubis/ui";
import { useAuth } from "@/lib/auth";
import { formatRubisToHours } from "@/lib/format";
import { cn } from "@/lib/utils";
diff --git a/apps/web/src/components/layout/AppTopbar.tsx b/apps/web/src/components/layout/AppTopbar.tsx
index 533fec1..42ce4d9 100644
--- a/apps/web/src/components/layout/AppTopbar.tsx
+++ b/apps/web/src/components/layout/AppTopbar.tsx
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
-import { Brand } from "@/components/brand/Brand";
+import { Brand } from "@rubis/ui";
import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils";
import { UserMenu } from "./UserMenu";
diff --git a/apps/web/src/components/plans/PlanCard.tsx b/apps/web/src/components/plans/PlanCard.tsx
index 033d8e1..e7a5244 100644
--- a/apps/web/src/components/plans/PlanCard.tsx
+++ b/apps/web/src/components/plans/PlanCard.tsx
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router";
import { Plus, ArrowRight, Sparkles, Copy as CopyIcon } from "lucide-react";
import type { Plan } from "@rubis/shared";
-import { Card } from "@/components/ui/Card";
+import { Card } from "@rubis/ui";
import { planMoodLabel } from "@/lib/plans";
import { cn } from "@/lib/utils";
diff --git a/apps/web/src/components/plans/wizard/AiGenerateModal.tsx b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx
index 3aadc4e..151c3f6 100644
--- a/apps/web/src/components/plans/wizard/AiGenerateModal.tsx
+++ b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx
@@ -15,7 +15,7 @@ import {
DialogFooter,
DialogClose,
} from "@/components/ui/Dialog";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Textarea } from "@/components/ui/Textarea";
import { EmailPreview } from "./EmailPreview";
diff --git a/apps/web/src/components/settings/AccountForm.tsx b/apps/web/src/components/settings/AccountForm.tsx
index 1eea83b..1fe0fd2 100644
--- a/apps/web/src/components/settings/AccountForm.tsx
+++ b/apps/web/src/components/settings/AccountForm.tsx
@@ -8,7 +8,7 @@ import type { User } from "@rubis/shared";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
diff --git a/apps/web/src/components/settings/DangerZone.tsx b/apps/web/src/components/settings/DangerZone.tsx
index 39a507c..2982415 100644
--- a/apps/web/src/components/settings/DangerZone.tsx
+++ b/apps/web/src/components/settings/DangerZone.tsx
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
/**
* Zone "danger" : déconnexion + suppression de compte (RGPD V2).
diff --git a/apps/web/src/components/settings/OrganizationForm.tsx b/apps/web/src/components/settings/OrganizationForm.tsx
index 0cacbbc..b2ad9ec 100644
--- a/apps/web/src/components/settings/OrganizationForm.tsx
+++ b/apps/web/src/components/settings/OrganizationForm.tsx
@@ -10,10 +10,10 @@ import {
} from "@rubis/shared";
import { api } from "@/lib/api";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
-import { Chip } from "@/components/ui/Chip";
+import { Chip } from "@rubis/ui";
/**
* Form section "Entreprise" — nom, SIRET, volume mensuel.
diff --git a/apps/web/src/components/settings/SettingsSection.tsx b/apps/web/src/components/settings/SettingsSection.tsx
index 8413a4d..274e0cd 100644
--- a/apps/web/src/components/settings/SettingsSection.tsx
+++ b/apps/web/src/components/settings/SettingsSection.tsx
@@ -1,5 +1,5 @@
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { cn } from "@/lib/utils";
/**
diff --git a/apps/web/src/components/settings/SignatureForm.tsx b/apps/web/src/components/settings/SignatureForm.tsx
index 741fb4b..9ff11f3 100644
--- a/apps/web/src/components/settings/SignatureForm.tsx
+++ b/apps/web/src/components/settings/SignatureForm.tsx
@@ -9,8 +9,8 @@ import type { User } from "@rubis/shared";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Textarea } from "@/components/ui/Textarea";
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index c5b4395..77a510b 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -1,10 +1,8 @@
-import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
-
/**
- * Combine et résout les conflits de classes Tailwind.
- * Utilisation : ``
+ * Re-export depuis @rubis/ui pour préserver les ~50 imports `@/lib/utils`
+ * existants. La source unique du `cn` est dans packages/ui/src/lib/cn.ts.
+ *
+ * À terme : migrer ces imports vers `from "@rubis/ui"` direct et supprimer
+ * ce shim. Pas urgent — pas de coût runtime, juste un fichier de plus.
*/
-export function cn(...inputs: ClassValue[]): string {
- return twMerge(clsx(inputs));
-}
+export { cn } from "@rubis/ui";
diff --git a/apps/web/src/routes/_app/clients.tsx b/apps/web/src/routes/_app/clients.tsx
index 0d5f406..1b15477 100644
--- a/apps/web/src/routes/_app/clients.tsx
+++ b/apps/web/src/routes/_app/clients.tsx
@@ -7,8 +7,8 @@ import { z } from "zod";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
-import { Button } from "@/components/ui/Button";
-import { EmptyState } from "@/components/ui/EmptyState";
+import { Button } from "@rubis/ui";
+import { EmptyState } from "@rubis/ui";
import {
ClientTable,
type ClientWithStats,
diff --git a/apps/web/src/routes/_app/clients_.$id.tsx b/apps/web/src/routes/_app/clients_.$id.tsx
index e12ed48..bab78f8 100644
--- a/apps/web/src/routes/_app/clients_.$id.tsx
+++ b/apps/web/src/routes/_app/clients_.$id.tsx
@@ -24,8 +24,8 @@ import {
} from "@/lib/format";
import { cn } from "@/lib/utils";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { Textarea } from "@/components/ui/Textarea";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { ClientPaidChart } from "@/components/charts/ClientPaidChart";
diff --git a/apps/web/src/routes/_app/factures.tsx b/apps/web/src/routes/_app/factures.tsx
index 76396ba..98e6446 100644
--- a/apps/web/src/routes/_app/factures.tsx
+++ b/apps/web/src/routes/_app/factures.tsx
@@ -19,7 +19,7 @@ import {
type InvoiceListItem,
} from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Pagination } from "@/components/ui/Pagination";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { uploadInvoiceFiles } from "@/lib/invoices";
diff --git a/apps/web/src/routes/_app/factures_.$id.tsx b/apps/web/src/routes/_app/factures_.$id.tsx
index cf4d418..d060af2 100644
--- a/apps/web/src/routes/_app/factures_.$id.tsx
+++ b/apps/web/src/routes/_app/factures_.$id.tsx
@@ -11,9 +11,9 @@ import { useCheckinStillPending } from "@/lib/checkin";
import { queryKeys } from "@/lib/queryKeys";
import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { Timeline, type TimelineEvent } from "@/components/ui/Timeline";
import { Textarea } from "@/components/ui/Textarea";
diff --git a/apps/web/src/routes/_app/factures_.import.tsx b/apps/web/src/routes/_app/factures_.import.tsx
index 0eabf07..44333dc 100644
--- a/apps/web/src/routes/_app/factures_.import.tsx
+++ b/apps/web/src/routes/_app/factures_.import.tsx
@@ -3,8 +3,8 @@ import { useMutation } from "@tanstack/react-query";
import { ArrowLeft, FilePlus } from "lucide-react";
import { toast } from "sonner";
-import { Button } from "@/components/ui/Button";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Button } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { Dropzone } from "@/components/factures/Dropzone";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { uploadInvoiceFiles } from "@/lib/invoices";
diff --git a/apps/web/src/routes/_app/factures_.import_.$batchId.tsx b/apps/web/src/routes/_app/factures_.import_.$batchId.tsx
index c1a2c3a..ee7e67f 100644
--- a/apps/web/src/routes/_app/factures_.import_.$batchId.tsx
+++ b/apps/web/src/routes/_app/factures_.import_.$batchId.tsx
@@ -24,10 +24,10 @@ import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { formatEuros } from "@/lib/format";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
import { DatePicker } from "@/components/ui/DatePicker";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Eyebrow } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import {
diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx
index adc75d4..d359231 100644
--- a/apps/web/src/routes/_app/index.tsx
+++ b/apps/web/src/routes/_app/index.tsx
@@ -6,9 +6,9 @@ import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { formatEuros } from "@/lib/format";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
diff --git a/apps/web/src/routes/_app/insights.tsx b/apps/web/src/routes/_app/insights.tsx
index 826920c..d544cd1 100644
--- a/apps/web/src/routes/_app/insights.tsx
+++ b/apps/web/src/routes/_app/insights.tsx
@@ -6,8 +6,8 @@ import { api } from "@/lib/api";
import { formatEuros } from "@/lib/format";
import { cn } from "@/lib/utils";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary";
import { EncaisseChart } from "@/components/charts/EncaisseChart";
diff --git a/apps/web/src/routes/_app/parametres.tsx b/apps/web/src/routes/_app/parametres.tsx
index 8444179..f6ce968 100644
--- a/apps/web/src/routes/_app/parametres.tsx
+++ b/apps/web/src/routes/_app/parametres.tsx
@@ -7,8 +7,8 @@ import { OrganizationForm } from "@/components/settings/OrganizationForm";
import { SignatureForm } from "@/components/settings/SignatureForm";
import { DangerZone } from "@/components/settings/DangerZone";
import { DemoToggle } from "@/components/demo/DemoToggle";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
import { useSubscription } from "@/lib/billing";
export const Route = createFileRoute("/_app/parametres")({
diff --git a/apps/web/src/routes/_app/parametres_.abonnement.tsx b/apps/web/src/routes/_app/parametres_.abonnement.tsx
index 617a3c3..d51973e 100644
--- a/apps/web/src/routes/_app/parametres_.abonnement.tsx
+++ b/apps/web/src/routes/_app/parametres_.abonnement.tsx
@@ -13,11 +13,11 @@ import {
} from "@/lib/billing";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/format";
-import { Gem } from "@/components/brand/Gem";
+import { Gem } from "@rubis/ui";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
const searchSchema = z.object({
checkout: z.enum(["success", "cancel"]).optional(),
diff --git a/apps/web/src/routes/_app/plans_.$slug.tsx b/apps/web/src/routes/_app/plans_.$slug.tsx
index b8c0ded..b9bdf33 100644
--- a/apps/web/src/routes/_app/plans_.$slug.tsx
+++ b/apps/web/src/routes/_app/plans_.$slug.tsx
@@ -23,9 +23,9 @@ import {
} from "@/lib/plans";
import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/Button";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Button } from "@rubis/ui";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
diff --git a/apps/web/src/routes/_app/plans_.nouveau.tsx b/apps/web/src/routes/_app/plans_.nouveau.tsx
index 9373f69..708881f 100644
--- a/apps/web/src/routes/_app/plans_.nouveau.tsx
+++ b/apps/web/src/routes/_app/plans_.nouveau.tsx
@@ -19,12 +19,12 @@ import { TONE_LABELS, TEMPLATE_VARIABLES, PREVIEW_VARS } from "@/lib/plans";
import { cn } from "@/lib/utils";
import { Stepper } from "@/components/ui/Stepper";
-import { Card } from "@/components/ui/Card";
-import { Button } from "@/components/ui/Button";
+import { Card } from "@rubis/ui";
+import { Button } from "@rubis/ui";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Eyebrow } from "@rubis/ui";
import { CadenceCalendar } from "@/components/plans/wizard/CadenceCalendar";
import { EmailPreview } from "@/components/plans/wizard/EmailPreview";
import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal";
diff --git a/apps/web/src/routes/auth.sso.complete.tsx b/apps/web/src/routes/auth.sso.complete.tsx
index 6078cea..9830b45 100644
--- a/apps/web/src/routes/auth.sso.complete.tsx
+++ b/apps/web/src/routes/auth.sso.complete.tsx
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import type { AuthSession } from "@rubis/shared";
import { api } from "@/lib/api";
import { authStore } from "@/lib/auth";
-import { Gem } from "@/components/brand/Gem";
+import { Gem } from "@rubis/ui";
/**
* Callback SSO côté SPA — partagé entre Google et Microsoft. Se charge :
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx
index 1a2e86d..a2aea33 100644
--- a/apps/web/src/routes/login.tsx
+++ b/apps/web/src/routes/login.tsx
@@ -10,12 +10,12 @@ import { loginSchema, type AuthSession, type LoginInput } from "@rubis/shared";
import { api, ApiError } from "@/lib/api";
import { authStore } from "@/lib/auth";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
-import { Eyebrow } from "@/components/ui/Eyebrow";
-import { Brand } from "@/components/brand/Brand";
-import { Gem } from "@/components/brand/Gem";
+import { Eyebrow } from "@rubis/ui";
+import { Brand } from "@rubis/ui";
+import { Gem } from "@rubis/ui";
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
const ssoErrorEnum = z
diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx
index f675207..fd4a273 100644
--- a/apps/web/src/routes/onboarding.tsx
+++ b/apps/web/src/routes/onboarding.tsx
@@ -7,7 +7,7 @@ import {
} from "@tanstack/react-router";
import { authStore } from "@/lib/auth";
-import { Brand } from "@/components/brand/Brand";
+import { Brand } from "@rubis/ui";
import { Stepper } from "@/components/ui/Stepper";
const ONBOARDING_STEPS = [
diff --git a/apps/web/src/routes/onboarding/compte.tsx b/apps/web/src/routes/onboarding/compte.tsx
index 7a40ebb..233b328 100644
--- a/apps/web/src/routes/onboarding/compte.tsx
+++ b/apps/web/src/routes/onboarding/compte.tsx
@@ -8,10 +8,10 @@ import { z } from "zod";
import type { User } from "@rubis/shared";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Eyebrow } from "@rubis/ui";
const accountSchema = z.object({
fullName: z
diff --git a/apps/web/src/routes/onboarding/entreprise.tsx b/apps/web/src/routes/onboarding/entreprise.tsx
index e5ccedc..8a224dd 100644
--- a/apps/web/src/routes/onboarding/entreprise.tsx
+++ b/apps/web/src/routes/onboarding/entreprise.tsx
@@ -7,11 +7,11 @@ import { z } from "zod";
import { MONTHLY_VOLUME_BUCKETS, type Organization } from "@rubis/shared";
import { api } from "@/lib/api";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
-import { Chip } from "@/components/ui/Chip";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Chip } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
const VOLUME_LABELS: Record<(typeof MONTHLY_VOLUME_BUCKETS)[number], string> = {
"moins-10": "Moins de 10",
diff --git a/apps/web/src/routes/onboarding/signature.tsx b/apps/web/src/routes/onboarding/signature.tsx
index 8821a0d..4b0baf4 100644
--- a/apps/web/src/routes/onboarding/signature.tsx
+++ b/apps/web/src/routes/onboarding/signature.tsx
@@ -8,11 +8,11 @@ import { z } from "zod";
import type { User } from "@rubis/shared";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Textarea } from "@/components/ui/Textarea";
import { Field } from "@/components/ui/Field";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
const signatureSchema = z.object({
signature: z.string().max(500, "500 caractères maximum"),
diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx
index 8ed8997..0e5fda4 100644
--- a/apps/web/src/routes/signup.tsx
+++ b/apps/web/src/routes/signup.tsx
@@ -12,13 +12,13 @@ import {
import { api, ApiError } from "@/lib/api";
import { authStore } from "@/lib/auth";
-import { Button } from "@/components/ui/Button";
+import { Button } from "@rubis/ui";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
-import { Card } from "@/components/ui/Card";
-import { Eyebrow } from "@/components/ui/Eyebrow";
-import { Brand } from "@/components/brand/Brand";
-import { Gem } from "@/components/brand/Gem";
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
+import { Brand } from "@rubis/ui";
+import { Gem } from "@rubis/ui";
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
export const Route = createFileRoute("/signup")({
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 6921f7f..1e22270 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -1,165 +1,21 @@
-/* ============================================================================
- * Rubis Sur l'Ongle — feuille de styles racine
- * Tailwind v4 (CSS-first) + tokens de marque (cf. /docs/marque.md)
- * ========================================================================== */
-
@import "tailwindcss";
-/* Polices self-hostées via fontsource (cf. /docs/tech/frontend.md §10) */
@import "@fontsource-variable/bricolage-grotesque";
@import "@fontsource-variable/inter";
-/* ----------------------------------------------------------------------------
- * Tokens de marque exposés en utilitaires Tailwind v4 via @theme.
- * Source : /docs/marque.md §3, §4
- * -------------------------------------------------------------------------- */
-@theme {
- /* === Couleurs rubis === */
- --color-rubis: #9f1239;
- --color-rubis-deep: #771328;
- --color-rubis-light: #c9415c;
- --color-rubis-glow: #fbe4ea;
+/* Design tokens + base layer + utilities (eyebrow, shadows…) — source unique
+ dans @rubis/ui pour partage avec apps/landing. NE PAS dupliquer ici. */
+@import "@rubis/ui/styles/tokens.css";
+@import "@rubis/ui/styles/base.css";
- /* === Neutres chauds (jamais de blanc/noir purs) === */
- --color-cream: #faf7f2;
- --color-cream-2: #f5efe7;
- --color-line: #e8e0d6;
- --color-ink: #1a1410;
- --color-ink-2: #4f4640;
- --color-ink-3: #8a7f76;
+/* Tailwind v4 : déclare explicitement les composants de @rubis/ui comme
+ sources, pour que le compilateur génère les classes qu'ils utilisent
+ dans le bundle final. Path relatif depuis ce fichier. */
+@source "../../../../packages/ui/src/**/*.{ts,tsx}";
- /* === Typographies === */
- --font-display: "Bricolage Grotesque Variable", "Bricolage Grotesque", -apple-system,
- BlinkMacSystemFont, "Segoe UI", sans-serif;
- --font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
- sans-serif;
-
- /* === Border radius — un peu plus tranchés que la default Tailwind ===
- 6px sur les éléments interactifs (cohérent avec la landing) */
- --radius-sharp: 4px;
- --radius-default: 6px;
- --radius-soft: 10px;
- --radius-card: 14px;
-
- /* === Ombres rubis-teintées === */
- --shadow-rubis: 0 2px 8px rgba(159, 18, 57, 0.25);
- --shadow-rubis-hover: 0 6px 16px rgba(159, 18, 57, 0.35);
- --shadow-card:
- 0 16px 40px -16px rgba(26, 20, 16, 0.18), 0 4px 8px -2px rgba(26, 20, 16, 0.06);
- --shadow-soft: 0 4px 16px rgba(26, 20, 16, 0.04);
-}
-
-/* ----------------------------------------------------------------------------
- * Globals
- * -------------------------------------------------------------------------- */
@layer base {
- html {
- -webkit-text-size-adjust: 100%;
- text-rendering: optimizeLegibility;
- }
-
- body {
- background: var(--color-cream);
- color: var(--color-ink);
- font-family: var(--font-sans);
- font-feature-settings: "ss01", "cv11";
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- line-height: 1.55;
- }
-
- /* Sélection rubis — petite signature partout */
- ::selection {
- background: var(--color-rubis);
- color: white;
- }
-
- /* Reset hr/fieldset minimal */
- fieldset {
- border: 0;
- padding: 0;
- margin: 0;
- }
-
- /* Curseur pointer par défaut sur tous les éléments interactifs activables.
- Tailwind v4 ne le pose plus automatiquement sur