feat: refactor frontend en stack React unifiée (Astro + packages/ui)
Trois surfaces partagent désormais le même design system, Tailwind v4
et React 19 — au lieu d'avoir landing en HTML vanilla, app en React, et
blog en Adonis SSR :
* packages/ui — design system partagé (tokens Tailwind v4 + composants
TSX) extrait depuis apps/web : Brand, Gem, Button, Card, Chip, Eyebrow,
EmptyState. apps/web migre 41 imports vers @rubis/ui.
* apps/landing — nouvelle app Astro 6 SSR (rubis.pro), remplace l'ancienne
landing nginx vanilla. Embarque :
- Landing complète portée en sections React (Hero, Stats, Promise,
HowItWorks, Gamification, Legal, Pricing, FAQ, FinalCTA, Footnotes)
- Pages légales (mentions, confidentialité, CGV) via LegalLayout.astro
- Blog SSR (/blog, /blog/:slug) qui consomme /api/v1/posts
- sitemap.xml, blog/rss.xml, robots.txt en endpoints Astro
- SEO complet (canonical, hreflang, OG, Twitter Card, JSON-LD
Article/BreadcrumbList/Blog/SoftwareApplication)
* apps/api — BlogController réduit à 2 endpoints JSON (GET /api/v1/posts
+ GET /api/v1/posts/:slug). Suppression des templates SSR Adonis
(apps/api/app/blog/), de l'alias #blog/*, des deps react-dom et
@types/react-dom. PostTransformer + PostSummaryTransformer ajoutés.
Le service blog_renderer + le seeder + les 3 articles fondateurs
restent intacts (réutilisés par futurs admin + cron IA).
* Infra :
- Dockerfile.landing (multi-stage Node 22 + tini, Astro standalone)
- k3s/app/landing.yml (Deployment + Service rubis-landing:4321 +
ConfigMap avec API_URL=http://rubis-api.rubis.svc.cluster.local:3333)
- .gitea/workflows/deploy.yml mis à jour pour build rubis-landing
- .gitea/workflows/deploy-web.yml + Dockerfile.web : prennent en
compte packages/ui/ comme dépendance
- Suppression du Dockerfile nginx legacy + k3s/{deployment,service}.yml
- Suppression de landing/ (assets favicons migrés vers
apps/landing/public/)
* Docs : architecture.md (vue d'ensemble + §4bis apps/landing complet,
§3 endpoints JSON blog, layout monorepo), CLAUDE.md (stack technique,
documents associés, déploiement).
Note infra : l'ancien Deployment "rubis" (nginx) et son Service ne sont
PAS supprimés par la CI — à nettoyer manuellement après validation que
Traefik a été repointé sur rubis-landing:4321 dans le repo proxmox.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ -8,6 +8,7 @@ on:
|
||||
paths:
|
||||
- 'apps/web/**'
|
||||
- 'packages/shared/**'
|
||||
- 'packages/ui/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
|
||||
@ -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
|
||||
|
||||
26
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.
|
||||
|
||||
|
||||
25
Dockerfile
@ -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
|
||||
71
Dockerfile.landing
Normal file
@ -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"]
|
||||
@ -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).
|
||||
|
||||
60
apps/api/app/controllers/blog_controller.ts
Normal file
@ -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()),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
38
apps/api/app/models/post.ts
Normal file
@ -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')
|
||||
})
|
||||
}
|
||||
103
apps/api/app/services/blog_renderer.ts
Normal file
@ -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 <br>, seul un \n\n crée un <p>)
|
||||
*/
|
||||
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 `<h${depth} id="${id}">${text}</h${depth}>\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 `<a href="${href}"${titleAttr}${relAttr}${targetAttr}>${text}</a>`
|
||||
},
|
||||
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 `<img src="${href}" ${altAttr}${titleAttr} loading="lazy" decoding="async" />`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function stripTags(s: string): string {
|
||||
return s.replace(/<[^>]+>/g, '')
|
||||
}
|
||||
53
apps/api/app/transformers/post_transformer.ts
Normal file
@ -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<Post> {
|
||||
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<Post> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
77
apps/api/commands/seed_blog.ts
Normal file
@ -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.`)
|
||||
}
|
||||
}
|
||||
@ -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 : <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')
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
117
apps/api/database/seeders/blog_seed/01_retards_paiement.ts
Normal file
@ -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.
|
||||
`,
|
||||
}
|
||||
113
apps/api/database/seeders/blog_seed/02_relancer_sans_casser.ts
Normal file
@ -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.
|
||||
`,
|
||||
}
|
||||
187
apps/api/database/seeders/blog_seed/03_modeles_email.ts
Normal file
@ -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.
|
||||
`,
|
||||
}
|
||||
7
apps/api/database/seeders/blog_seed/index.ts
Normal file
@ -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]
|
||||
@ -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",
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
1
apps/landing/.env.development
Normal file
@ -0,0 +1 @@
|
||||
API_URL=http://localhost:3333
|
||||
4
apps/landing/.env.example
Normal file
@ -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
|
||||
15
apps/landing/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Build & cache Astro
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs / OS
|
||||
*.log
|
||||
.DS_Store
|
||||
36
apps/landing/astro.config.mjs
Normal file
@ -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,
|
||||
},
|
||||
});
|
||||
35
apps/landing/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 309 KiB |
50
apps/landing/src/components/SiteFooter.tsx
Normal file
@ -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 (
|
||||
<footer className="border-t border-line bg-cream-2 mt-24">
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-12">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Brand withSuffix gemSize={24} />
|
||||
<p className="text-[13px] text-ink-3 max-w-md">
|
||||
Le SaaS de relance de factures impayées pour TPE-PME françaises.
|
||||
Fait à Paris, avec du temps libéré.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Liens utiles" className="flex flex-wrap gap-x-6 gap-y-2 text-[13px]">
|
||||
<a href="/blog" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
Blog
|
||||
</a>
|
||||
<a href="/mentions-legales" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
Mentions légales
|
||||
</a>
|
||||
<a href="/confidentialite" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
Confidentialité
|
||||
</a>
|
||||
<a href="/cgv" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
CGV
|
||||
</a>
|
||||
<a
|
||||
href="mailto:contact@rubis.pro"
|
||||
className="text-ink-2 hover:text-rubis transition-colors"
|
||||
>
|
||||
contact@rubis.pro
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-line text-[12px] text-ink-3">
|
||||
© {CURRENT_YEAR} Rubis sur l'ongle. Tous droits réservés.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
56
apps/landing/src/components/SiteHeader.tsx
Normal file
@ -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 (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-40 w-full",
|
||||
solid
|
||||
? "border-b border-line bg-cream/92 backdrop-blur-md"
|
||||
: "bg-cream/80 backdrop-blur-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 h-[68px] flex items-center justify-between gap-6">
|
||||
<a href="/" className="flex items-center hover:no-underline">
|
||||
<Brand withSuffix gemSize={26} />
|
||||
</a>
|
||||
|
||||
<nav aria-label="Navigation principale" className="flex items-center gap-1.5 sm:gap-3">
|
||||
<a
|
||||
href="/#pricing"
|
||||
className="hidden sm:inline-flex h-10 items-center px-3 text-[14px] font-medium text-ink-2 hover:text-rubis transition-colors"
|
||||
>
|
||||
Tarifs
|
||||
</a>
|
||||
<a
|
||||
href="/blog"
|
||||
className="hidden sm:inline-flex h-10 items-center px-3 text-[14px] font-medium text-ink-2 hover:text-rubis transition-colors"
|
||||
>
|
||||
Blog
|
||||
</a>
|
||||
<Button asChild size="sm">
|
||||
<a href={APP_URL}>Essai gratuit 30 j</a>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
61
apps/landing/src/components/blog/PostCard.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { Gem, cn } from "@rubis/ui";
|
||||
|
||||
import type { PostSummary } from "../../lib/api";
|
||||
|
||||
const DATE_FMT = new Intl.DateTimeFormat("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
export function PostCard({
|
||||
post,
|
||||
className,
|
||||
}: {
|
||||
post: PostSummary;
|
||||
className?: string;
|
||||
}) {
|
||||
const dateLabel = post.publishedAt ? DATE_FMT.format(new Date(post.publishedAt)) : null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/blog/${post.slug}`}
|
||||
className={cn(
|
||||
"group flex flex-col bg-white border border-line rounded-card overflow-hidden",
|
||||
"transition-all duration-150",
|
||||
"hover:no-underline hover:-translate-y-0.5 hover:border-rubis-light hover:shadow-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="aspect-[16/9] flex items-center justify-center bg-gradient-to-br from-rubis-glow to-cream-2">
|
||||
{post.heroImageUrl ? (
|
||||
<img
|
||||
src={post.heroImageUrl}
|
||||
alt={post.heroImageAlt ?? post.title}
|
||||
loading="lazy"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Gem size={56} className="opacity-40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<div className="flex items-center gap-2 text-[11.5px] uppercase tracking-[0.08em] font-semibold text-ink-3 mb-3">
|
||||
{dateLabel && (
|
||||
<time dateTime={post.publishedAt ?? undefined}>{dateLabel}</time>
|
||||
)}
|
||||
{dateLabel && <span aria-hidden className="size-[3px] rounded-full bg-ink-3" />}
|
||||
<span>{post.readingTimeMinutes} min de lecture</span>
|
||||
</div>
|
||||
<h2 className="font-display font-bold text-ink text-[20px] tracking-[-0.018em] leading-[1.2]">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p className="mt-3 flex-1 text-[14.5px] leading-relaxed text-ink-2">{post.excerpt}</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1.5 font-display font-semibold text-[14px] text-rubis">
|
||||
Lire l'article
|
||||
<span className="transition-transform group-hover:translate-x-0.5">→</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
138
apps/landing/src/components/sections/FAQ.tsx
Normal file
@ -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 :{" "}
|
||||
<i>« Avez-vous été payé pour la facture F-2024-0042 ? »</i> 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 (
|
||||
<code className="bg-cream-2 px-1.5 py-0.5 rounded text-[13px]">{"{{prenom_client}}"}</code>,{" "}
|
||||
<code className="bg-cream-2 px-1.5 py-0.5 rounded text-[13px]">{"{{numero}}"}</code>,{" "}
|
||||
<code className="bg-cream-2 px-1.5 py-0.5 rounded text-[13px]">{"{{montant}}"}</code>…).
|
||||
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 <b>Pro</b>, vos clients voient <b>votre nom</b> en grand comme
|
||||
expéditeur, et quand ils cliquent « Répondre », leur message revient directement sur{" "}
|
||||
<b>votre email</b>. Aucun pied de page ne mentionne Rubis. Suffisant pour 95 % des
|
||||
freelances et TPE.
|
||||
<br />
|
||||
<br />
|
||||
En plan <b>Business</b>, on va plus loin : vos emails partent vraiment depuis{" "}
|
||||
<b>votre propre adresse</b> (
|
||||
<code className="bg-cream-2 px-1.5 py-0.5 rounded text-[13px]">
|
||||
compta@votre-entreprise.fr
|
||||
</code>
|
||||
). Personne ne devine que vous utilisez un outil, et vos relances atterrissent{" "}
|
||||
<b>mieux en boîte principale</b> 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 <b>vous</b> 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{" "}
|
||||
<b>2 clics</b>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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. <b>1 rubis = 10 minutes libérées</b> = 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 (
|
||||
<section id="faq" className="bg-cream-2 border-y border-line">
|
||||
<div className="max-w-[760px] mx-auto px-5 sm:px-8 py-20 lg:py-24">
|
||||
<div className="text-center mb-12">
|
||||
<Eyebrow>Questions fréquentes</Eyebrow>
|
||||
<h2 className="mt-4 font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px]">
|
||||
Vous vous demandez sûrement…
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{FAQS.map(({ q, a }, i) => (
|
||||
<details
|
||||
key={i}
|
||||
className="group bg-white border border-line rounded-card overflow-hidden hover:border-ink-3 transition-colors"
|
||||
>
|
||||
<summary className="px-6 py-5 cursor-pointer list-none flex items-start justify-between gap-4 font-display font-semibold text-[16.5px] text-ink tracking-[-0.012em] leading-snug select-none [&::-webkit-details-marker]:hidden">
|
||||
{q}
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex-shrink-0 size-7 rounded-full bg-cream-2 border border-line flex items-center justify-center text-ink-2 group-open:rotate-45 transition-transform"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-6 pb-6 -mt-1 text-[15.5px] leading-relaxed text-ink-2">{a}</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
apps/landing/src/components/sections/FinalCTA.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Button } from "@rubis/ui";
|
||||
|
||||
const APP_URL = "https://app.rubis.pro";
|
||||
|
||||
export function FinalCTA() {
|
||||
return (
|
||||
<section id="lancer">
|
||||
<div className="max-w-[820px] mx-auto px-5 sm:px-8 py-24 lg:py-28 text-center">
|
||||
<h2 className="font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[36px] sm:text-[48px]">
|
||||
Récupérez vos premières heures dès aujourd'hui.
|
||||
</h2>
|
||||
<p className="mt-5 text-[17.5px] text-ink-2 leading-relaxed max-w-[580px] mx-auto">
|
||||
30 jours gratuits, puis le plan Free continue avec 5 factures actives. Pas de carte
|
||||
demandée pour démarrer.
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button asChild size="lg">
|
||||
<a href={APP_URL}>Lancer Rubis →</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-5 text-[13px] text-ink-3">
|
||||
Inscription en 30 secondes. Annulation 1-clic à tout moment.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
14
apps/landing/src/components/sections/Footnotes.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
export function Footnotes() {
|
||||
return (
|
||||
<aside className="border-t border-line bg-cream">
|
||||
<div className="max-w-[820px] mx-auto px-5 sm:px-8 py-8">
|
||||
<p className="text-[13.5px] text-ink-3 leading-relaxed">
|
||||
<span className="text-rubis font-semibold mr-1.5">*</span>
|
||||
<b className="text-ink-2">OCR</b> — pour <i>Optical Character Recognition</i>. La
|
||||
reconnaissance automatique du texte sur un PDF ou une photo. La machine lit votre
|
||||
facture par-dessus votre épaule, en somme.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
46
apps/landing/src/components/sections/Gamification.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Eyebrow, Gem } from "@rubis/ui";
|
||||
|
||||
export function Gamification() {
|
||||
return (
|
||||
<section>
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-24">
|
||||
<div className="bg-gradient-to-br from-rubis to-rubis-deep text-cream rounded-card p-8 sm:p-14 text-center shadow-card">
|
||||
<div className="inline-flex">
|
||||
<Eyebrow tone="ink" className="!text-rubis-glow">
|
||||
La devise du temps gagné
|
||||
</Eyebrow>
|
||||
</div>
|
||||
<h2 className="mt-5 font-display font-bold leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px] lg:text-[52px]">
|
||||
1 rubis = 10 minutes de votre vie.
|
||||
</h2>
|
||||
<p className="mt-5 max-w-[680px] mx-auto text-[17px] text-cream/85 leading-relaxed">
|
||||
À 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.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center gap-3">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<Gem size={56} className="!text-cream" />
|
||||
<span className="font-display font-extrabold text-[88px] sm:text-[120px] leading-none tracking-[-0.04em]">
|
||||
124
|
||||
</span>
|
||||
<span className="font-display font-medium text-[28px] sm:text-[36px] tracking-[-0.02em] text-cream/80">
|
||||
rubis
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[16px] text-cream/85">
|
||||
≈ <b className="text-cream">24 h 48</b> de votre mois
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-10 max-w-[460px] mx-auto text-[14px] text-cream/70">
|
||||
Et oui, on garde un classement amical. Les meilleurs utilisateurs libèrent{" "}
|
||||
<b className="text-cream">30 heures par mois</b>. Plus de quoi prendre un long
|
||||
week-end. Toutes les 4 semaines.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
162
apps/landing/src/components/sections/Hero.tsx
Normal file
@ -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 (
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Halo rubis discret en haut-droite */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-40 -right-40 size-[480px] rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, var(--color-rubis-glow) 0%, transparent 65%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 pt-16 pb-20 lg:pt-24 lg:pb-28 grid lg:grid-cols-[1.1fr_1fr] gap-12 lg:gap-16 items-center relative">
|
||||
{/* ============ Texte ============ */}
|
||||
<div>
|
||||
<Eyebrow>L'outil de relance pour TPE-PME françaises</Eyebrow>
|
||||
|
||||
<h1 className="mt-5 font-display font-extrabold text-ink leading-[1.05] tracking-[-0.03em] text-[44px] sm:text-[56px] lg:text-[64px] max-w-[680px]">
|
||||
Vos factures relancées <em>toutes seules</em> pendant que vous travaillez.
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 text-[18px] sm:text-[19px] leading-relaxed text-ink-2 max-w-[580px] md:text-justify hyphens-auto">
|
||||
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{" "}
|
||||
<b className="text-ink">5 heures par semaine</b>.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<a href={APP_URL}>Lancer Rubis →</a>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="lg">
|
||||
<a href="#pricing">Voir les tarifs</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap items-center gap-x-3 gap-y-2 text-[13px] text-ink-3">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check size={14} className="text-rubis" aria-hidden />
|
||||
30 jours gratuits puis Free 5 factures
|
||||
</span>
|
||||
<span aria-hidden className="size-[3px] rounded-full bg-ink-3" />
|
||||
<span>Hébergement souverain</span>
|
||||
<span aria-hidden className="size-[3px] rounded-full bg-ink-3" />
|
||||
<span>Made in France 🇫🇷</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ 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.
|
||||
*/}
|
||||
<div className="relative w-full max-w-[480px] mx-auto lg:ml-auto lg:mr-0">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white border border-line rounded-card shadow-card",
|
||||
"p-6 sm:p-7 lg:p-8",
|
||||
)}
|
||||
>
|
||||
{/* Hero rubis */}
|
||||
<div className="flex items-center gap-4 pb-5 border-b border-line">
|
||||
<Gem size={56} glow />
|
||||
<div>
|
||||
<div className="font-display font-bold text-[32px] tracking-[-0.022em] leading-none text-ink">
|
||||
124 rubis
|
||||
</div>
|
||||
<div className="mt-1.5 text-[14px] text-ink-2">
|
||||
≈ <b className="text-ink">24 h 48</b> que vous n'avez pas passées à relancer.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-5 mt-5">
|
||||
<div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.06em] font-semibold text-ink-3">
|
||||
Encaissé
|
||||
</div>
|
||||
<div className="mt-1.5 font-display font-bold text-[22px] tracking-[-0.015em] text-ink tabular-nums">
|
||||
14 320 €
|
||||
</div>
|
||||
<div className="mt-1 text-[11.5px] text-rubis font-medium">
|
||||
+ 2 800 € vs avril
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.06em] font-semibold text-ink-3">
|
||||
DSO
|
||||
</div>
|
||||
<div className="mt-1.5 font-display font-bold text-[22px] tracking-[-0.015em] text-ink tabular-nums">
|
||||
38 j
|
||||
</div>
|
||||
<div className="mt-1 text-[11.5px] text-rubis font-medium">
|
||||
↘ −6 j depuis Rubis
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
<div className="mt-5 pt-4 border-t border-dashed border-line">
|
||||
<div className="text-[11px] uppercase tracking-[0.08em] font-semibold text-ink-3 mb-2.5">
|
||||
Aujourd'hui
|
||||
</div>
|
||||
<ul className="space-y-2 text-[13px]">
|
||||
<li className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-ink-2">
|
||||
📤 Relance envoyée à <b className="text-ink">Atelier Durand</b>
|
||||
</span>
|
||||
<time className="text-ink-3 tabular-nums text-[11.5px]">11:14</time>
|
||||
</li>
|
||||
<li className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-ink-2">
|
||||
✓ Facture <b className="text-ink">F-2024-0035</b> encaissée
|
||||
</span>
|
||||
<time className="text-ink-3 tabular-nums text-[11.5px]">10:02</time>
|
||||
</li>
|
||||
<li className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-ink-2">📥 3 factures importées et OCRisées</span>
|
||||
<time className="text-ink-3 tabular-nums text-[11.5px]">09:48</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge flottant — relatif au wrapper carte (max-w 480) */}
|
||||
<div className="absolute -bottom-3 left-4 sm:left-6 bg-ink text-cream rounded-full px-4 py-2 text-[12.5px] font-semibold flex items-center gap-1.5 shadow-card">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
~3 minutes le matin
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
220
apps/landing/src/components/sections/HowItWorks.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import { Eyebrow, cn } from "@rubis/ui";
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section id="how" className="bg-cream-2 border-y border-line">
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-28">
|
||||
<div className="text-center max-w-[640px] mx-auto mb-16">
|
||||
<Eyebrow>Comment ça marche</Eyebrow>
|
||||
<h2 className="mt-4 font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px]">
|
||||
Trois étapes. C'est tout.
|
||||
</h2>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">
|
||||
Vraiment. Parfois deux, si votre plan par défaut est bien réglé.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Step
|
||||
num="01"
|
||||
title="Vous importez vos factures."
|
||||
body="PDF, photo prise depuis votre téléphone à la caisse, scan reçu par mail — peu importe. L'OCR* lit, extrait montant, client, échéance, RIB. Vous vérifiez. Vingt secondes par facture, montre en main."
|
||||
>
|
||||
<DropzoneWidget />
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
flip
|
||||
num="02"
|
||||
title="Vous choisissez un plan de relance."
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-3">Et bien sûr, vous pouvez aussi créer les vôtres sur mesure.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CalendarWidget />
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
num="03"
|
||||
title="Et puis c'est tout."
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-5 inline-flex items-center gap-2 px-4 py-2.5 bg-rubis-glow border border-rubis/15 rounded-default text-[14px] text-rubis-deep font-medium">
|
||||
<span aria-hidden className="size-[7px] bg-rubis rotate-45" />
|
||||
La récompense : votre compteur de rubis grimpe. Tranquillement. Comme une bonne
|
||||
nouvelle régulière.
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AssistantWidget />
|
||||
</Step>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type StepProps = {
|
||||
num: string;
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
flip?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Step({ num, title, body, flip = false, children }: StepProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Mobile + tablet : 1 colonne, prose pleine largeur (juste le padding du container).
|
||||
// Desktop : 2 colonnes avec prose cappée par la cellule de grille.
|
||||
"grid lg:grid-cols-2 gap-8 lg:gap-16 items-center py-10 md:py-14",
|
||||
"border-b border-dashed border-line last:border-b-0",
|
||||
flip && "lg:[&>*:first-child]:order-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center lg:items-start gap-5 md:gap-6">
|
||||
<div className="inline-flex items-center font-display font-bold text-[12px] tracking-[0.16em] uppercase text-rubis bg-rubis-glow border border-rubis/15 px-3 py-1.5 rounded-full">
|
||||
Étape {num}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
<div className="w-full lg:max-w-[520px]">
|
||||
<h3 className="font-display font-bold text-ink text-[26px] sm:text-[30px] lg:text-[32px] tracking-[-0.022em] leading-[1.15]">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-4 md:mt-5 text-[16.5px] md:text-[17px] leading-relaxed text-ink-2 md:text-justify hyphens-auto">
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============== Widget 01 — Dropzone ============== */
|
||||
function DropzoneWidget() {
|
||||
return (
|
||||
<div className="relative w-full max-w-[420px] aspect-[5/4] bg-white border border-dashed border-rubis/40 rounded-card p-6 flex flex-col items-center justify-center gap-3 shadow-soft">
|
||||
<div className="flex items-center gap-2" aria-hidden>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-3 bg-rubis rotate-45 opacity-60"
|
||||
style={{ opacity: 1 - i * 0.25 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-rubis text-2xl font-bold" aria-hidden>↓</div>
|
||||
<div className="w-full max-w-[260px] bg-cream-2 border border-line rounded-default p-4 space-y-1.5">
|
||||
<div className="h-1.5 bg-ink-3/30 rounded w-2/3" />
|
||||
<div className="h-1.5 bg-ink-3/30 rounded w-1/2" />
|
||||
<div className="h-1.5 bg-ink-3/30 rounded w-full" />
|
||||
<div className="h-1.5 bg-ink-3/30 rounded w-2/3" />
|
||||
<div className="font-display font-bold text-ink text-[18px] tabular-nums mt-3 pt-3 border-t border-line">
|
||||
1 240,00 €
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[12px] text-ink-3 font-mono">facture-2024-0042.pdf</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============== Widget 02 — Calendrier ============== */
|
||||
function CalendarWidget() {
|
||||
const days = Array.from({ length: 35 }, (_, i) => i - 3); // 4 jours vides, puis 1-31
|
||||
const marked = new Set([8, 15, 25]);
|
||||
return (
|
||||
<div className="w-full max-w-[420px] bg-white border border-line rounded-card p-5 shadow-soft">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="font-display font-bold text-ink">Mai 2026</div>
|
||||
<div className="flex items-center gap-1 text-ink-3 text-[14px]">
|
||||
<span className="size-7 inline-flex items-center justify-center rounded border border-line cursor-pointer hover:bg-cream-2">
|
||||
‹
|
||||
</span>
|
||||
<span className="size-7 inline-flex items-center justify-center rounded border border-line cursor-pointer hover:bg-cream-2">
|
||||
›
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5 text-center text-[12px]">
|
||||
{["L", "M", "M", "J", "V", "S", "D"].map((d, i) => (
|
||||
<div key={i} className="text-ink-3 font-semibold uppercase tracking-wider pb-2">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{days.map((d, i) => {
|
||||
if (d < 1) return <div key={i} className="text-ink-3/30 py-1.5">·</div>;
|
||||
const isMarked = marked.has(d);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"py-1.5 rounded text-[12.5px] tabular-nums",
|
||||
isMarked
|
||||
? "bg-rubis text-white font-semibold"
|
||||
: "text-ink hover:bg-cream-2",
|
||||
)}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-line text-[11.5px]">
|
||||
<span className="inline-flex items-center gap-1.5 text-ink-2">
|
||||
<span aria-hidden className="size-2 rounded-full bg-rubis" />
|
||||
Relances programmées
|
||||
</span>
|
||||
<span className="text-ink-3">3 étapes · plan B2B</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============== Widget 03 — Assistant (mini illustration) ============== */
|
||||
function AssistantWidget() {
|
||||
return (
|
||||
<div className="w-full max-w-[420px] aspect-[5/4] bg-white border border-line rounded-card p-6 flex flex-col items-center justify-center gap-4 shadow-soft">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-14 rounded-full bg-rubis text-white flex items-center justify-center font-display font-bold text-[20px]">
|
||||
◆
|
||||
</div>
|
||||
<div className="bg-cream-2 border border-line rounded-default px-4 py-3 text-[13.5px] text-ink-2 max-w-[220px] relative">
|
||||
Cette facture a-t-elle été réglée ?
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-[-7px] top-1/2 -translate-y-1/2 size-3 rotate-45 bg-cream-2 border-l border-b border-line"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-rubis text-white rounded-default text-[13px] font-semibold shadow-rubis"
|
||||
>
|
||||
✓ Oui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-white border border-line text-ink rounded-default text-[13px] font-medium"
|
||||
>
|
||||
Pas encore
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[12px] text-ink-3 italic">2 secondes, et la machine s'occupe du reste.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
apps/landing/src/components/sections/Legal.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { Eyebrow } from "@rubis/ui";
|
||||
|
||||
const SANCTIONED = [
|
||||
["Fnac Darty", "3,9 M€"],
|
||||
["Cdiscount", "2,1 M€"],
|
||||
["Sanofi", "1,65 M€"],
|
||||
["LCL", "1,5 M€"],
|
||||
];
|
||||
|
||||
export function Legal() {
|
||||
return (
|
||||
<section className="bg-cream-2 border-y border-line">
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-24 grid lg:grid-cols-[1.3fr_1fr] gap-12 lg:gap-16 items-start">
|
||||
<div>
|
||||
<Eyebrow>Vous êtes dans votre droit</Eyebrow>
|
||||
<h2 className="mt-4 font-display font-bold text-ink leading-[1.12] tracking-[-0.025em] text-[32px] sm:text-[42px]">
|
||||
La loi est de votre côté. On vous évite juste de la brandir.
|
||||
</h2>
|
||||
|
||||
<div className="mt-6 space-y-5 text-[17px] leading-relaxed text-ink-2">
|
||||
<p>
|
||||
La{" "}
|
||||
<span className="inline-flex items-center px-2 py-0.5 bg-rubis-glow border border-rubis/15 rounded text-rubis-deep font-semibold text-[14px] tracking-tight">
|
||||
loi LME
|
||||
</span>{" "}
|
||||
plafonne les délais de paiement entre entreprises à <b className="text-ink">60 jours</b>{" "}
|
||||
(ou 45 jours fin de mois). Les sanctions peuvent atteindre{" "}
|
||||
<b className="text-ink">2 millions d'euros</b>. En 2025, le Sénat a voté à
|
||||
l'unanimité un durcissement supplémentaire des règles.
|
||||
</p>
|
||||
<p>
|
||||
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 <b className="text-ink">vous</b> qui
|
||||
l'envoyez, sur validation manuelle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clipping presse-style */}
|
||||
<aside className="bg-white border border-line rounded-card p-7 shadow-soft">
|
||||
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.14em] font-semibold text-ink-3 pb-3 mb-4 border-b border-line">
|
||||
<span>Sanctions DGCCRF</span>
|
||||
<span>2025</span>
|
||||
</div>
|
||||
|
||||
<div className="font-display font-extrabold text-rubis text-[56px] sm:text-[68px] leading-none tracking-[-0.035em]">
|
||||
47 M€
|
||||
</div>
|
||||
<p className="mt-2 text-[15px] text-ink-2 leading-snug">
|
||||
de pénalités prononcées contre les mauvais payeurs français l'an dernier.
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 divide-y divide-line">
|
||||
{SANCTIONED.map(([name, amount]) => (
|
||||
<li key={name} className="flex items-center justify-between py-2.5 text-[14.5px]">
|
||||
<b className="text-ink font-semibold">{name}</b>
|
||||
<span className="text-rubis font-display font-bold tabular-nums">{amount}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="py-2.5 text-[13px] italic text-ink-3">
|
||||
… et 405 autres entreprises contrôlées
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="mt-4 pt-4 border-t border-line text-[11.5px] text-ink-3">
|
||||
Source · DGCCRF, bilan annuel 2025 · economie.gouv.fr
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
164
apps/landing/src/components/sections/Pricing.tsx
Normal file
@ -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 (
|
||||
<section id="pricing">
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-28">
|
||||
<div className="text-center max-w-[640px] mx-auto mb-14">
|
||||
<Eyebrow>Tarifs</Eyebrow>
|
||||
<h2 className="mt-4 font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px]">
|
||||
Moins cher qu'une heure de votre temps mensuel.
|
||||
</h2>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">
|
||||
On va droit au but. Un plan principal qu'on recommande à 99 % d'entre vous, et deux
|
||||
options autour. C'est tout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pro plan — anchor */}
|
||||
<div className="bg-white border border-line rounded-card shadow-card grid lg:grid-cols-[1.05fr_1fr] gap-0 overflow-hidden">
|
||||
<div className="p-8 lg:p-12 border-b lg:border-b-0 lg:border-r border-line">
|
||||
<span className="inline-flex items-center text-[11px] uppercase tracking-[0.16em] font-semibold text-rubis bg-rubis-glow border border-rubis/15 px-3 py-1.5 rounded-full">
|
||||
Le plan qu'on recommande
|
||||
</span>
|
||||
<h3 className="mt-5 font-display font-bold text-ink text-[32px] sm:text-[40px] tracking-[-0.025em] leading-tight">
|
||||
Le plan <em>Pro</em>.
|
||||
</h3>
|
||||
<div className="mt-6 flex items-baseline gap-3">
|
||||
<span className="font-display font-extrabold text-rubis text-[68px] sm:text-[88px] tracking-[-0.04em] leading-none tabular-nums">
|
||||
19 €
|
||||
</span>
|
||||
<span className="font-sans text-[14px] text-ink-3 leading-tight">
|
||||
par mois
|
||||
<br />
|
||||
hors taxes
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-6 text-[16.5px] leading-relaxed text-ink-2 max-w-md">
|
||||
Pour ce prix, vous avez Rubis dans son <b className="text-ink">intégralité</b>.
|
||||
Factures et OCR illimités, plans de relance personnalisés, statistiques détaillées,
|
||||
support prioritaire. Aucun palier caché, aucun surcoût à l'usage.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Button asChild size="lg">
|
||||
<a href={APP_URL}>Commencer l'essai 30 jours →</a>
|
||||
</Button>
|
||||
<span className="text-[13px] text-ink-3">
|
||||
Sans engagement, annulable à tout moment
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 lg:p-12 bg-cream-2">
|
||||
<div className="font-display font-bold text-[14px] uppercase tracking-[0.08em] text-ink mb-5">
|
||||
Ce qui est inclus
|
||||
</div>
|
||||
<ul className="space-y-3.5">
|
||||
{FEATURES.map(([head, tail]) => (
|
||||
<li key={head} className="flex gap-3 text-[15px] leading-snug text-ink-2">
|
||||
<Check
|
||||
size={18}
|
||||
strokeWidth={2.5}
|
||||
className="text-rubis flex-shrink-0 mt-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>
|
||||
<b className="text-ink">{head}</b> {tail}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asides */}
|
||||
<div className="text-center my-12 text-[14px] uppercase tracking-[0.2em] font-semibold text-ink-3">
|
||||
— ou —
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-5 lg:gap-7">
|
||||
<PricingAside
|
||||
name="Plan Free"
|
||||
price="0 €"
|
||||
qualifier="Pour tester ou démarrer en freelance"
|
||||
>
|
||||
Le plan Free fait tourner Rubis sur <b className="text-ink">5 factures actives</b> en
|
||||
permanence. Gratuit, pour de bon. Notre façon de prouver que la promesse tient.
|
||||
</PricingAside>
|
||||
<PricingAside
|
||||
name="Plan Business"
|
||||
price="49 €"
|
||||
qualifier="Pour les équipes & les pros exigeants"
|
||||
>
|
||||
Tout du Pro, plus : <b className="text-ink">jusqu'à 5 collaborateurs</b> dans la boîte,
|
||||
chacun avec son accès. Vos relances partent de{" "}
|
||||
<b className="text-ink">votre vraie adresse pro</b> (
|
||||
<code className="bg-cream-2 px-1.5 py-0.5 rounded text-[12.5px]">
|
||||
compta@votre-entreprise.fr
|
||||
</code>
|
||||
), pas d'une adresse Rubis — vos clients ne se rendent compte de rien et vos emails
|
||||
arrivent mieux en boîte principale.
|
||||
</PricingAside>
|
||||
</div>
|
||||
|
||||
<p className="mt-12 text-center text-[14px] text-ink-3 max-w-[560px] mx-auto">
|
||||
Pas de palier caché. Pas de surcoût à l'usage. Annulation en un clic, sans question
|
||||
posée.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingAside({
|
||||
name,
|
||||
price,
|
||||
qualifier,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
price: string;
|
||||
qualifier: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={APP_URL}
|
||||
className="group block bg-white border border-line rounded-card p-7 shadow-soft hover:border-rubis/40 hover:shadow-card transition-all hover:no-underline"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3 pb-4 border-b border-line">
|
||||
<span className="font-display font-bold text-ink text-[20px] tracking-[-0.018em]">
|
||||
{name}
|
||||
</span>
|
||||
<span className="font-display font-extrabold text-ink text-[24px] tabular-nums">
|
||||
{price}
|
||||
<span className="font-sans font-normal text-[13px] text-ink-3 ml-1">/mois</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-[12.5px] uppercase tracking-[0.1em] font-semibold text-ink-3">
|
||||
{qualifier}
|
||||
</div>
|
||||
<p className="mt-3 text-[15px] leading-relaxed text-ink-2">{children}</p>
|
||||
<span className="mt-5 inline-flex items-center gap-1.5 text-[14px] font-display font-semibold text-rubis">
|
||||
Démarrer {name.replace("Plan ", "")} maintenant
|
||||
<span className="transition-transform group-hover:translate-x-0.5">→</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
58
apps/landing/src/components/sections/Promise.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Eyebrow } from "@rubis/ui";
|
||||
|
||||
export function Promise() {
|
||||
return (
|
||||
<section>
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-28">
|
||||
<div className="text-center max-w-[820px] mx-auto">
|
||||
<Eyebrow>Notre conviction</Eyebrow>
|
||||
<blockquote className="mt-6 font-display font-bold text-ink leading-[1.05] tracking-[-0.03em] text-[40px] sm:text-[56px] lg:text-[64px]">
|
||||
Votre temps vaut <em>plus que ça</em>.
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid lg:grid-cols-[1.4fr_1fr] gap-10 lg:gap-16 items-start max-w-[1080px] mx-auto">
|
||||
<div className="space-y-5 md:text-justify hyphens-auto">
|
||||
<p className="text-[17.5px] leading-relaxed text-ink-2">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-[17.5px] leading-relaxed text-ink-2">
|
||||
Les PME qui automatisent leurs relances passent de{" "}
|
||||
<b className="text-ink">8 heures par semaine</b> à{" "}
|
||||
<b className="text-ink">moins de 3</b>. Soit 5 heures de votre vie récupérées.
|
||||
Toutes les semaines. Pour toujours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-cream-2 border border-line rounded-card p-7">
|
||||
<h4 className="font-display font-bold text-[15px] text-ink uppercase tracking-[0.06em]">
|
||||
Votre temps en chiffres
|
||||
</h4>
|
||||
<dl className="mt-4 divide-y divide-line">
|
||||
{[
|
||||
["Heures perdues / semaine", "5 h"],
|
||||
["Sur un mois", "~ 21 h"],
|
||||
["À 50 €/h facturés", "1 050 €"],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className="flex items-center justify-between py-3 text-[14px]">
|
||||
<dt className="text-ink-2">{label}</dt>
|
||||
<dd className="font-display font-bold text-ink tabular-nums">{val}</dd>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between pt-4 mt-1 border-t-2 border-ink">
|
||||
<dt className="text-ink font-semibold text-[14px]">
|
||||
Coût annuel d'une relance manuelle
|
||||
</dt>
|
||||
<dd className="font-display font-extrabold text-rubis text-[20px] tabular-nums">
|
||||
12 600 €
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
55
apps/landing/src/components/sections/Stats.tsx
Normal file
@ -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 (
|
||||
<section className="bg-cream-2 border-y border-line">
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-24">
|
||||
<div className="text-center max-w-[640px] mx-auto mb-12">
|
||||
<Eyebrow>L'état des paiements en France</Eyebrow>
|
||||
<h2 className="mt-4 font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px]">
|
||||
Trois chiffres qui devraient vous fâcher.
|
||||
</h2>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">
|
||||
Si vous lisez ça, vous avez probablement une facture impayée à l'heure où on parle.
|
||||
Vous n'êtes pas un cas isolé.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-5 lg:gap-7">
|
||||
{STATS.map((s) => (
|
||||
<div
|
||||
key={s.num}
|
||||
className="bg-white border border-line rounded-card p-7 lg:p-8 shadow-soft"
|
||||
>
|
||||
<div className="font-display font-extrabold text-rubis text-[44px] sm:text-[52px] tracking-[-0.03em] leading-none">
|
||||
{s.num}
|
||||
</div>
|
||||
<p className="mt-4 text-[15.5px] text-ink leading-relaxed">{s.desc}</p>
|
||||
<p className="mt-4 pt-4 border-t border-line text-[12px] text-ink-3 italic">
|
||||
{s.source}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
115
apps/landing/src/layouts/Layout.astro
Normal file
@ -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
|
||||
* <slot /> 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]) : [];
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="robots" content={robots} />
|
||||
<meta name="theme-color" content="#9F1239" />
|
||||
<meta name="author" content="Rubis sur l'ongle" />
|
||||
|
||||
<link rel="canonical" href={url} />
|
||||
<link rel="alternate" hreflang="fr-FR" href={url} />
|
||||
<link rel="alternate" hreflang="x-default" href={url} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:site_name" content="Rubis sur l'ongle" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={url} />
|
||||
{
|
||||
ogImage && (
|
||||
<>
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={title} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Twitter Card */}
|
||||
<meta name="twitter:card" content={ogImage ? "summary_large_image" : "summary"} />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
||||
|
||||
{/* Favicons */}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
{/* RSS auto-discovery */}
|
||||
<link rel="alternate" type="application/rss+xml" title="Blog Rubis" href={`${SITE_URL}/blog/rss.xml`} />
|
||||
|
||||
{/* JSON-LD structured data */}
|
||||
{
|
||||
jsonLdArray.map((data) => (
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(data)} />
|
||||
))
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<SiteHeader solid={solidHeader} />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</body>
|
||||
</html>
|
||||
204
apps/landing/src/layouts/LegalLayout.astro
Normal file
@ -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 <em>...</em>). */
|
||||
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;
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} solidHeader>
|
||||
<article class="legal-prose">
|
||||
<div class="max-w-[760px] mx-auto px-5 sm:px-8 py-16 lg:py-20">
|
||||
<p
|
||||
class="text-[11px] uppercase tracking-[0.16em] font-semibold text-rubis flex items-center gap-2"
|
||||
>
|
||||
<span aria-hidden class="size-[7px] bg-current rotate-45 inline-block"></span>
|
||||
{eyebrow}
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class="mt-5 font-display font-bold text-ink leading-[1.05] tracking-[-0.025em] text-[40px] sm:text-[52px]"
|
||||
set:html={h1}
|
||||
/>
|
||||
|
||||
<p class="mt-6 text-[18px] text-ink-2 leading-relaxed">{lede}</p>
|
||||
<p class="mt-3 text-[13px] text-ink-3 italic">Dernière mise à jour : {lastUpdated}</p>
|
||||
|
||||
<div class="mt-12 prose-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style is:global>
|
||||
.prose-content {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.prose-content > * + * {
|
||||
margin-top: 1.4em;
|
||||
}
|
||||
.prose-content h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
margin-top: 2.6em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.prose-content h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.3;
|
||||
margin-top: 2em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.prose-content p,
|
||||
.prose-content li {
|
||||
font-size: 16.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.prose-content strong {
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-content em {
|
||||
font-style: italic;
|
||||
color: var(--color-rubis);
|
||||
}
|
||||
.prose-content a {
|
||||
color: var(--color-rubis);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.prose-content a:hover {
|
||||
color: var(--color-rubis-deep);
|
||||
}
|
||||
.prose-content ul,
|
||||
.prose-content ol {
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
.prose-content ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.prose-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.prose-content li + li {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.prose-content code {
|
||||
background: var(--color-cream-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.prose-content .toc {
|
||||
background: var(--color-cream-2);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 14px;
|
||||
padding: 22px 28px;
|
||||
margin: 2.5em 0;
|
||||
}
|
||||
.prose-content .toc-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-ink);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.prose-content .toc ol {
|
||||
counter-reset: toc;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
}
|
||||
.prose-content .toc li {
|
||||
counter-increment: toc;
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
.prose-content .toc li::before {
|
||||
content: counter(toc, decimal-leading-zero);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 1px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--color-rubis);
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.prose-content .toc a {
|
||||
text-decoration: none;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.prose-content .toc a:hover {
|
||||
color: var(--color-rubis);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.prose-content .callout {
|
||||
background: var(--color-rubis-glow);
|
||||
border-left: 3px solid var(--color-rubis);
|
||||
padding: 16px 20px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin: 1.6em 0;
|
||||
}
|
||||
.prose-content .callout p {
|
||||
margin: 0;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
.prose-content .def-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14.5px;
|
||||
margin: 1.4em 0;
|
||||
}
|
||||
.prose-content .def-table thead th {
|
||||
text-align: left;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
padding: 12px 14px;
|
||||
background: var(--color-cream-2);
|
||||
border-bottom: 2px solid var(--color-line);
|
||||
}
|
||||
.prose-content .def-table tbody td {
|
||||
padding: 12px 14px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.prose-content .def-table tbody td:first-child {
|
||||
width: 38%;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
</Layout>
|
||||
63
apps/landing/src/lib/api.ts
Normal file
@ -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<T>(path: string): Promise<T | null> {
|
||||
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<PostSummary[]> {
|
||||
return (await apiGet<PostSummary[]>("/api/v1/posts")) ?? [];
|
||||
}
|
||||
|
||||
export async function getPost(slug: string): Promise<PostShowResponse | null> {
|
||||
return await apiGet<PostShowResponse>(`/api/v1/posts/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
271
apps/landing/src/pages/blog/[slug].astro
Normal file
@ -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",
|
||||
);
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={post.title}
|
||||
description={post.excerpt}
|
||||
ogImage={ogImage}
|
||||
ogType="article"
|
||||
noindex={post.noindex}
|
||||
pathname={`/blog/${post.slug}`}
|
||||
solidHeader
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<article>
|
||||
<header class="bg-cream-2 border-b border-line">
|
||||
<div class="max-w-[760px] mx-auto px-5 sm:px-8 py-16 text-center">
|
||||
<p
|
||||
class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] font-semibold text-rubis"
|
||||
>
|
||||
<span aria-hidden class="size-[7px] bg-current rotate-45 inline-block"></span>
|
||||
Article
|
||||
</p>
|
||||
<h1
|
||||
class="mt-5 font-display font-extrabold text-ink leading-[1.08] tracking-[-0.022em] text-[34px] sm:text-[48px]"
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<div class="mt-6 flex items-center justify-center gap-3 text-[14px] text-ink-3">
|
||||
{dateLabel && <time datetime={dateIso}>{dateLabel}</time>}
|
||||
{dateLabel && <span aria-hidden class="size-[3px] rounded-full bg-ink-3"></span>}
|
||||
<span>{post.readingTimeMinutes} min de lecture</span>
|
||||
{post.authorName && <span aria-hidden class="size-[3px] rounded-full bg-ink-3"></span>}
|
||||
{post.authorName && <span>par {post.authorName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{
|
||||
post.heroImageUrl && (
|
||||
<figure class="max-w-[920px] mx-auto px-5 sm:px-8 mt-10">
|
||||
<img
|
||||
src={post.heroImageUrl}
|
||||
alt={post.heroImageAlt ?? post.title}
|
||||
class="w-full aspect-[16/9] object-cover rounded-card"
|
||||
/>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="max-w-[720px] mx-auto px-5 sm:px-8 py-12 lg:py-16">
|
||||
<div class="article-body" set:html={post.contentHtml} />
|
||||
|
||||
<aside
|
||||
class="mt-16 bg-gradient-to-br from-rubis to-rubis-deep text-cream rounded-card p-8 sm:p-10 text-center shadow-card"
|
||||
>
|
||||
<h3
|
||||
class="font-display font-bold text-[24px] sm:text-[28px] tracking-[-0.02em] leading-[1.2]"
|
||||
>
|
||||
Vos factures relancées toutes seules pendant que vous travaillez.
|
||||
</h3>
|
||||
<p class="mt-3 text-cream/85 text-[16px] max-w-[520px] mx-auto">
|
||||
Rubis automatise vos relances avec la bonne tonalité, au bon moment. 30 jours
|
||||
d'essai, sans carte bancaire.
|
||||
</p>
|
||||
<a
|
||||
href="https://app.rubis.pro"
|
||||
class="mt-6 inline-flex items-center justify-center bg-cream text-rubis font-display font-bold text-[15px] px-6 py-3 rounded-default hover:bg-white transition-colors"
|
||||
>
|
||||
Tester Rubis gratuitement
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{
|
||||
related.length > 0 && (
|
||||
<section class="bg-cream-2 border-t border-line">
|
||||
<div class="max-w-[1180px] mx-auto px-5 sm:px-8 py-16">
|
||||
<h2
|
||||
class="font-display font-bold text-[24px] tracking-[-0.02em] text-ink text-center mb-10"
|
||||
>
|
||||
À lire aussi
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
{related.map((rp) => <PostCard post={rp} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<style is:global>
|
||||
.article-body > * + * {
|
||||
margin-top: 1.4em;
|
||||
}
|
||||
.article-body h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 2.4em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.article-body h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 21px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.015em;
|
||||
margin-top: 2em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.article-body p,
|
||||
.article-body li {
|
||||
font-size: 17.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.article-body ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.article-body ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.article-body li + li {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
.article-body strong {
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.article-body em {
|
||||
font-style: italic;
|
||||
}
|
||||
.article-body a {
|
||||
color: var(--color-rubis);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.article-body a:hover {
|
||||
color: var(--color-rubis-deep);
|
||||
}
|
||||
.article-body blockquote {
|
||||
border-left: 3px solid var(--color-rubis);
|
||||
padding: 4px 0 4px 24px;
|
||||
font-style: italic;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.article-body code {
|
||||
background: var(--color-cream-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.article-body pre {
|
||||
background: var(--color-ink);
|
||||
color: var(--color-cream);
|
||||
padding: 20px 24px;
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.article-body pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.article-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-line);
|
||||
margin: 3em 0;
|
||||
}
|
||||
.article-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 15px;
|
||||
}
|
||||
.article-body th,
|
||||
.article-body td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.article-body th {
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
background: var(--color-cream-2);
|
||||
}
|
||||
.article-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
</Layout>
|
||||
85
apps/landing/src/pages/blog/index.astro
Normal file
@ -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",
|
||||
);
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} solidHeader jsonLd={jsonLd}>
|
||||
<section class="bg-cream-2 border-b border-line">
|
||||
<div class="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-24 text-center">
|
||||
<p
|
||||
class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] font-semibold text-rubis"
|
||||
>
|
||||
<span aria-hidden class="size-[7px] bg-current rotate-45 inline-block"></span>
|
||||
Blog Rubis
|
||||
</p>
|
||||
<h1
|
||||
class="mt-5 font-display font-extrabold text-ink leading-[1.05] tracking-[-0.025em] text-[40px] sm:text-[56px] max-w-[780px] mx-auto"
|
||||
>
|
||||
Le blog des relances qui marchent
|
||||
</h1>
|
||||
<p class="mt-5 max-w-[640px] mx-auto text-[18px] text-ink-2 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-[1180px] mx-auto px-5 sm:px-8 py-16">
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<div class="text-center py-16 text-ink-3">
|
||||
<p>Aucun article publié pour l'instant. Reviens vite.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
{posts.map((post) => <PostCard post={post} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</Layout>
|
||||
63
apps/landing/src/pages/blog/rss.xml.ts
Normal file
@ -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, """)
|
||||
.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 <link rel="alternate" type="application/rss+xml">.
|
||||
*/
|
||||
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 ` <item>
|
||||
<title>${escapeXml(p.title)}</title>
|
||||
<link>${escapeXml(url)}</link>
|
||||
<guid isPermaLink="true">${escapeXml(url)}</guid>
|
||||
<pubDate>${toRfc2822(p.publishedAt)}</pubDate>
|
||||
<description>${escapeXml(p.excerpt)}</description>
|
||||
<author>contact@rubis.pro (${escapeXml(p.authorName)})</author>
|
||||
</item>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Rubis sur l'ongle — Blog</title>
|
||||
<link>${SITE}/blog</link>
|
||||
<atom:link href="${SITE}/blog/rss.xml" rel="self" type="application/rss+xml" />
|
||||
<description>Stratégies, modèles d'email et retours du terrain pour relancer vos factures impayées sans abîmer la relation client.</description>
|
||||
<language>fr-FR</language>
|
||||
<lastBuildDate>${lastBuild}</lastBuildDate>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
};
|
||||
165
apps/landing/src/pages/cgv.astro
Normal file
@ -0,0 +1,165 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
import LegalLayout from "../layouts/LegalLayout.astro";
|
||||
---
|
||||
|
||||
<LegalLayout
|
||||
title="Conditions Générales de Vente"
|
||||
description="Conditions Générales de Vente du service Rubis sur l'ongle — modalités contractuelles entre l'éditeur et les utilisateurs professionnels."
|
||||
eyebrow="Conditions contractuelles"
|
||||
h1={`Conditions Générales <em>de Vente</em>`}
|
||||
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"
|
||||
>
|
||||
<div class="toc" aria-label="Sommaire">
|
||||
<p class="toc-title">Sommaire</p>
|
||||
<ol>
|
||||
<li><a href="#definitions">Définitions</a></li>
|
||||
<li><a href="#objet">Objet</a></li>
|
||||
<li><a href="#acceptation">Acceptation des CGV</a></li>
|
||||
<li><a href="#compte">Création du compte</a></li>
|
||||
<li><a href="#service">Description du service</a></li>
|
||||
<li><a href="#tarifs">Tarifs, essai et facturation</a></li>
|
||||
<li><a href="#duree">Durée et résiliation</a></li>
|
||||
<li><a href="#disponibilite">Disponibilité du service</a></li>
|
||||
<li><a href="#obligations">Obligations de l'utilisateur</a></li>
|
||||
<li><a href="#propriete">Propriété intellectuelle</a></li>
|
||||
<li><a href="#donnees">Données personnelles</a></li>
|
||||
<li><a href="#responsabilite">Responsabilité</a></li>
|
||||
<li><a href="#force-majeure">Force majeure</a></li>
|
||||
<li><a href="#modifications">Modification des CGV</a></li>
|
||||
<li><a href="#cession">Cession</a></li>
|
||||
<li><a href="#droit">Droit applicable et juridiction</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2 id="definitions">1. Définitions</h2>
|
||||
<ul>
|
||||
<li><strong>Éditeur</strong> : Arthur Barré, entrepreneur individuel, responsable de la publication et de l'édition du service Rubis sur l'ongle (cf. <a href="/mentions-legales">mentions légales</a>).</li>
|
||||
<li><strong>Utilisateur</strong> : toute personne physique ou morale exerçant à titre professionnel qui crée un compte et utilise le service.</li>
|
||||
<li><strong>Service</strong> : la plateforme SaaS « Rubis sur l'ongle » accessible depuis <a href="https://app.rubis.pro">app.rubis.pro</a>, permettant d'automatiser la relance de factures impayées.</li>
|
||||
<li><strong>Compte</strong> : espace utilisateur protégé par un identifiant et un mot de passe permettant d'accéder au service.</li>
|
||||
<li><strong>Plan</strong> : formule d'abonnement choisie par l'utilisateur (Free, Pro ou Business) déterminant le périmètre des fonctionnalités et le tarif.</li>
|
||||
<li><strong>Client final</strong> : tiers à qui l'utilisateur facture des prestations et à qui les emails de relance sont adressés.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="objet">2. Objet</h2>
|
||||
<p>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 :</p>
|
||||
<ul>
|
||||
<li>Importer des factures (PDF, image, ou saisie manuelle) ;</li>
|
||||
<li>Configurer des plans de relance avec emails programmés ;</li>
|
||||
<li>Envoyer automatiquement ces relances à ses clients finaux selon la cadence choisie ;</li>
|
||||
<li>Suivre le statut des factures, valider les paiements, gérer les mises en demeure.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="acceptation">3. Acceptation des CGV</h2>
|
||||
<p>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 <a href="/confidentialite">politique de confidentialité</a>.</p>
|
||||
<p>L'utilisateur déclare agir dans le cadre d'une activité professionnelle. Le service n'est <strong>pas destiné aux consommateurs</strong> au sens du Code de la consommation.</p>
|
||||
|
||||
<h2 id="compte">4. Création du compte</h2>
|
||||
<p>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.</p>
|
||||
<p>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 à <a href="mailto:contact@rubis.pro">contact@rubis.pro</a>.</p>
|
||||
|
||||
<h2 id="service">5. Description du service</h2>
|
||||
<p>Le service comprend, selon le plan souscrit, les fonctionnalités suivantes :</p>
|
||||
<table class="def-table">
|
||||
<thead>
|
||||
<tr><th>Plan</th><th>Périmètre principal</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Free</strong></td><td>Jusqu'à 5 factures actives en relance simultanément. Plans de relance standards. OCR limité. Un seul utilisateur.</td></tr>
|
||||
<tr><td><strong>Pro</strong></td><td>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.</td></tr>
|
||||
<tr><td><strong>Business</strong></td><td>Tout du Pro, plus jusqu'à 5 collaborateurs, envoi des relances depuis le domaine propre de l'utilisateur (configuration DKIM/SPF), onboarding personnel.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>La liste détaillée et à jour des fonctionnalités est disponible sur la page <a href="/#pricing">tarifs</a> du site.</p>
|
||||
|
||||
<h2 id="tarifs">6. Tarifs, essai et facturation</h2>
|
||||
|
||||
<h3>6.1 Tarifs</h3>
|
||||
<p>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).</p>
|
||||
|
||||
<h3>6.2 Période d'essai gratuite</h3>
|
||||
<p>Tout nouvel utilisateur bénéficie d'une période d'essai de <strong>30 jours</strong> 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).</p>
|
||||
|
||||
<h3>6.3 Modalités de paiement</h3>
|
||||
<p>Le paiement des plans payants s'effectue en ligne via notre prestataire de paiement <strong>Stripe</strong>, par carte bancaire ou prélèvement SEPA. Aucune donnée bancaire n'est stockée sur les serveurs de l'éditeur.</p>
|
||||
<p>Le règlement est effectué d'avance, par mensualités successives, à la date anniversaire de la souscription.</p>
|
||||
|
||||
<h3>6.4 Facturation</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h3>6.5 Défaut de paiement</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h3>6.6 Évolution tarifaire</h3>
|
||||
<p>L'éditeur se réserve le droit de modifier ses tarifs. Toute évolution est notifiée par email à l'utilisateur au moins <strong>30 jours</strong> avant son entrée en vigueur. L'utilisateur peut alors résilier son abonnement avant l'application du nouveau tarif sans pénalité.</p>
|
||||
|
||||
<h2 id="duree">7. Durée et résiliation</h2>
|
||||
|
||||
<h3>7.1 Durée</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h3>7.2 Résiliation par l'utilisateur</h3>
|
||||
<p>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û.</p>
|
||||
<p>La suppression complète du compte (et de toutes les données associées) peut être demandée par email à <a href="mailto:contact@rubis.pro">contact@rubis.pro</a> ou via le bouton dédié dans les paramètres. Voir la <a href="/confidentialite">politique de confidentialité</a> pour les durées de conservation post-suppression.</p>
|
||||
|
||||
<h3>7.3 Résiliation par l'éditeur</h3>
|
||||
<p>L'éditeur peut résilier le compte de l'utilisateur, sans préavis ni indemnité, dans les cas suivants :</p>
|
||||
<ul>
|
||||
<li>Défaut de paiement persistant (au-delà de 30 jours) ;</li>
|
||||
<li>Violation manifeste des présentes CGV ou de la législation en vigueur ;</li>
|
||||
<li>Utilisation du service à des fins illicites, frauduleuses, de harcèlement ou de spam ;</li>
|
||||
<li>Atteinte à la sécurité ou à l'intégrité du service.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="disponibilite">8. Disponibilité du service</h2>
|
||||
<p>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.</p>
|
||||
<p>Les opérations de maintenance planifiée sont annoncées à l'avance par email ou notification dans l'application lorsque possible.</p>
|
||||
|
||||
<h2 id="obligations">9. Obligations de l'utilisateur</h2>
|
||||
<p>L'utilisateur s'engage à :</p>
|
||||
<ul>
|
||||
<li>Utiliser le service conformément à sa destination, à la législation française et aux usages commerciaux loyaux ;</li>
|
||||
<li>Respecter le cadre légal applicable aux relances de factures, notamment la <strong>loi LME</strong> sur les délais de paiement et les règles de procédure civile concernant la mise en demeure ;</li>
|
||||
<li>S'assurer du caractère professionnel et licite de ses relations commerciales avec ses clients finaux ;</li>
|
||||
<li>Ne pas utiliser le service pour adresser des communications non sollicitées (spam) à des destinataires sans relation commerciale préexistante ;</li>
|
||||
<li>Disposer du droit d'utilisation des données client (email, nom, etc.) qu'il renseigne dans le service ;</li>
|
||||
<li>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.</li>
|
||||
</ul>
|
||||
<p>La <strong>mise en demeure</strong> est traitée par le service comme une étape de plan particulière nécessitant une <strong>validation manuelle</strong> de l'utilisateur avant envoi. L'éditeur ne saurait être tenu responsable du contenu ou de la qualification juridique de cette mise en demeure.</p>
|
||||
|
||||
<h2 id="propriete">10. Propriété intellectuelle</h2>
|
||||
<p>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.</p>
|
||||
<p>L'utilisateur conserve l'intégralité de la propriété sur les <strong>données qu'il importe ou crée</strong> 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.</p>
|
||||
|
||||
<h2 id="donnees">11. Données personnelles</h2>
|
||||
<p>Le traitement des données personnelles par l'éditeur est régi par notre <a href="/confidentialite">politique de confidentialité</a>, qui fait partie intégrante des présentes CGV.</p>
|
||||
<p>Pour les données des <strong>clients finaux</strong> de l'utilisateur (emails, noms, etc.), l'éditeur agit en tant que <strong>sous-traitant</strong> au sens de l'article 28 du RGPD. Un avenant DPA (Data Processing Agreement) peut être fourni sur demande à <a href="mailto:contact@rubis.pro">contact@rubis.pro</a>.</p>
|
||||
|
||||
<h2 id="responsabilite">12. Responsabilité</h2>
|
||||
<p>L'éditeur fournit un outil d'assistance à l'envoi de relances automatisées. L'<strong>utilisateur reste seul responsable</strong> :</p>
|
||||
<ul>
|
||||
<li>Du contenu des emails envoyés à ses clients ;</li>
|
||||
<li>De la légalité et de la qualité de la relation commerciale qui motive la relance ;</li>
|
||||
<li>De la qualification juridique éventuelle d'une mise en demeure ;</li>
|
||||
<li>De l'exactitude des données saisies dans le service.</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
<p>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é.</p>
|
||||
|
||||
<h2 id="force-majeure">13. Force majeure</h2>
|
||||
<p>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.).</p>
|
||||
|
||||
<h2 id="modifications">14. Modification des CGV</h2>
|
||||
<p>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 <strong>30 jours</strong> avant son entrée en vigueur.</p>
|
||||
<p>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.</p>
|
||||
|
||||
<h2 id="cession">15. Cession</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2 id="droit">16. Droit applicable et juridiction</h2>
|
||||
<p>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.</p>
|
||||
<p>Avant toute action contentieuse, les parties s'engagent à rechercher une solution amiable, par échange écrit à <a href="mailto:contact@rubis.pro">contact@rubis.pro</a>, dans un délai raisonnable.</p>
|
||||
</LegalLayout>
|
||||
150
apps/landing/src/pages/confidentialite.astro
Normal file
@ -0,0 +1,150 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
import LegalLayout from "../layouts/LegalLayout.astro";
|
||||
---
|
||||
|
||||
<LegalLayout
|
||||
title="Politique de confidentialité"
|
||||
description="Politique de confidentialité de Rubis sur l'ongle — comment nous collectons, utilisons et protégeons vos données personnelles, conformément au RGPD."
|
||||
eyebrow="Données personnelles · RGPD"
|
||||
h1={`Politique de <em>confidentialité</em>`}
|
||||
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"
|
||||
>
|
||||
<div class="toc" aria-label="Sommaire">
|
||||
<p class="toc-title">Sommaire</p>
|
||||
<ol>
|
||||
<li><a href="#responsable">Responsable du traitement</a></li>
|
||||
<li><a href="#donnees">Données collectées</a></li>
|
||||
<li><a href="#finalites">Finalités et bases légales</a></li>
|
||||
<li><a href="#sous-traitants">Sous-traitants et hébergement</a></li>
|
||||
<li><a href="#duree">Durée de conservation</a></li>
|
||||
<li><a href="#droits">Vos droits</a></li>
|
||||
<li><a href="#cookies">Cookies</a></li>
|
||||
<li><a href="#securite">Sécurité</a></li>
|
||||
<li><a href="#reclamation">Réclamation auprès de la CNIL</a></li>
|
||||
<li><a href="#evolutions">Évolutions de cette politique</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2 id="responsable">1. Responsable du traitement</h2>
|
||||
<p>
|
||||
Le responsable du traitement de vos données est <strong>Arthur Barré</strong>, éditeur du service Rubis sur l'ongle (cf. <a href="/mentions-legales">mentions légales</a>).
|
||||
</p>
|
||||
<div class="callout">
|
||||
<p>
|
||||
<strong>Pour toute question liée à vos données</strong> :
|
||||
<a href="mailto:contact@rubis.pro">contact@rubis.pro</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 id="donnees">2. Données collectées</h2>
|
||||
<p>
|
||||
Nous collectons uniquement les données strictement nécessaires au fonctionnement du service. Aucune donnée n'est revendue à des tiers à des fins commerciales.
|
||||
</p>
|
||||
|
||||
<h3>2.1 Données d'inscription et de compte</h3>
|
||||
<ul>
|
||||
<li><strong>Email</strong> (identifiant de connexion + canal de notification check-in)</li>
|
||||
<li><strong>Mot de passe</strong> (stocké de manière chiffrée et irréversible)</li>
|
||||
<li><strong>Nom complet</strong> (signature des relances)</li>
|
||||
<li><strong>Nom de l'organisation et SIRET</strong> (optionnel, pour les mises en demeure formelles)</li>
|
||||
</ul>
|
||||
|
||||
<h3>2.2 Données métier saisies par l'utilisateur</h3>
|
||||
<ul>
|
||||
<li><strong>Factures</strong> : numéro, montant, dates d'émission et d'échéance, fichier PDF/image source</li>
|
||||
<li><strong>Clients du user</strong> : nom, email, téléphone, adresse, SIRET (si fourni)</li>
|
||||
<li><strong>Plans de relance</strong> et templates d'emails que l'utilisateur configure</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Important</strong> : les données des <em>clients finaux</em> 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.
|
||||
</p>
|
||||
|
||||
<h3>2.3 Données techniques et journaux</h3>
|
||||
<ul>
|
||||
<li>Journaux de connexion (adresse IP, date) — conservés à des fins de sécurité</li>
|
||||
<li>Cookie de session pour maintenir la connexion</li>
|
||||
<li>Identifiants techniques de notre prestataire de paiement (en cas d'abonnement payant)</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="finalites">3. Finalités et bases légales</h2>
|
||||
<table class="def-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Finalité</th>
|
||||
<th>Base légale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Fourniture du service (relances, OCR, dashboard)</td><td>Exécution du contrat (art. 6.1.b RGPD)</td></tr>
|
||||
<tr><td>Facturation et abonnement</td><td>Exécution du contrat + obligation légale (comptable)</td></tr>
|
||||
<tr><td>Sécurité du service (anti-fraude, logs)</td><td>Intérêt légitime (art. 6.1.f)</td></tr>
|
||||
<tr><td>Support utilisateur</td><td>Exécution du contrat</td></tr>
|
||||
<tr><td>Statistiques d'usage anonymisées</td><td>Intérêt légitime — pas de profiling individuel</td></tr>
|
||||
<tr><td>Communication commerciale</td><td>Consentement explicite (opt-in à l'inscription)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 id="sous-traitants">4. Sous-traitants et hébergement</h2>
|
||||
<p>
|
||||
L'application, la base de données et les fichiers utilisateurs sont hébergés <strong>en France</strong>. Trois sous-traitants spécialisés interviennent pour des fonctions ciblées :
|
||||
</p>
|
||||
<table class="def-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sous-traitant</th>
|
||||
<th>Rôle & localisation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Stripe</strong></td><td>Traitement des paiements. Hébergement : Union européenne.</td></tr>
|
||||
<tr><td><strong>Resend</strong></td><td>Envoi des emails transactionnels et de relance. Hébergement : Union européenne (clauses contractuelles types pour les éventuels traitements hors UE).</td></tr>
|
||||
<tr><td><strong>Mistral AI</strong></td><td>Reconnaissance du texte sur les factures importées. Hébergement : France.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Aucun transfert de données hors Union européenne n'a lieu sans encadrement contractuel approprié.</p>
|
||||
|
||||
<h2 id="duree">5. Durée de conservation</h2>
|
||||
<ul>
|
||||
<li><strong>Compte utilisateur actif</strong> : conservation tant que le compte est ouvert.</li>
|
||||
<li><strong>Compte supprimé</strong> : suppression complète des données dans un délai de 30 jours, hors obligations comptables.</li>
|
||||
<li><strong>Factures émises et payées</strong> : conservation 10 ans (obligation comptable, art. L.123-22 du Code de commerce).</li>
|
||||
<li><strong>Logs techniques</strong> : 30 jours.</li>
|
||||
<li><strong>Données Stripe (factures d'abonnement)</strong> : selon politique de Stripe (en général 10 ans).</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="droits">6. Vos droits</h2>
|
||||
<p>Conformément au RGPD, vous disposez à tout moment des droits suivants :</p>
|
||||
<ul>
|
||||
<li><strong>Droit d'accès</strong> à vos données personnelles</li>
|
||||
<li><strong>Droit de rectification</strong> en cas de données inexactes</li>
|
||||
<li><strong>Droit à l'effacement</strong> (« droit à l'oubli »)</li>
|
||||
<li><strong>Droit à la portabilité</strong> dans un format lisible (export JSON sur demande)</li>
|
||||
<li><strong>Droit à la limitation</strong> du traitement</li>
|
||||
<li><strong>Droit d'opposition</strong> au traitement pour motif légitime</li>
|
||||
<li><strong>Droit de retirer votre consentement</strong> à tout moment, sans rétroactivité</li>
|
||||
</ul>
|
||||
<p>
|
||||
Pour exercer ces droits, écrivez-nous à <a href="mailto:contact@rubis.pro">contact@rubis.pro</a> 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).
|
||||
</p>
|
||||
|
||||
<h2 id="cookies">7. Cookies</h2>
|
||||
<p>Nous utilisons exclusivement des <strong>cookies strictement nécessaires</strong> au fonctionnement du service. Aucun cookie publicitaire ni de mesure d'audience tierce n'est déposé.</p>
|
||||
<p>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).</p>
|
||||
|
||||
<h2 id="securite">8. Sécurité</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2 id="reclamation">9. Réclamation auprès de la CNIL</h2>
|
||||
<p>
|
||||
Si vous estimez, après nous avoir contactés, que vos droits ne sont pas respectés, vous pouvez adresser une réclamation à la
|
||||
<a href="https://www.cnil.fr/fr/plaintes" target="_blank" rel="noopener">
|
||||
Commission Nationale de l'Informatique et des Libertés (CNIL)
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="evolutions">10. Évolutions de cette politique</h2>
|
||||
<p>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.</p>
|
||||
</LegalLayout>
|
||||
57
apps/landing/src/pages/index.astro
Normal file
@ -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" },
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} jsonLd={jsonLd}>
|
||||
<Hero />
|
||||
<Stats />
|
||||
<PromiseSection />
|
||||
<HowItWorks />
|
||||
<Gamification />
|
||||
<Legal />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<FinalCTA />
|
||||
<Footnotes />
|
||||
</Layout>
|
||||
94
apps/landing/src/pages/mentions-legales.astro
Normal file
@ -0,0 +1,94 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
import LegalLayout from "../layouts/LegalLayout.astro";
|
||||
---
|
||||
|
||||
<LegalLayout
|
||||
title="Mentions légales"
|
||||
description="Mentions légales du site Rubis sur l'ongle — éditeur, hébergeur, propriété intellectuelle, droit applicable."
|
||||
eyebrow="Informations légales"
|
||||
h1={`Mentions <em>légales</em>`}
|
||||
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"
|
||||
>
|
||||
<div class="toc" aria-label="Sommaire">
|
||||
<p class="toc-title">Sommaire</p>
|
||||
<ol>
|
||||
<li><a href="#editeur">Éditeur du site</a></li>
|
||||
<li><a href="#hebergeur">Hébergement</a></li>
|
||||
<li><a href="#publication">Directeur de la publication</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
<li><a href="#propriete">Propriété intellectuelle</a></li>
|
||||
<li><a href="#responsabilite">Limitation de responsabilité</a></li>
|
||||
<li><a href="#liens">Liens externes</a></li>
|
||||
<li><a href="#cookies">Cookies</a></li>
|
||||
<li><a href="#droit">Droit applicable</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2 id="editeur">1. Éditeur du site</h2>
|
||||
<p>
|
||||
Le site <strong>rubis.pro</strong> (ainsi que l'application <strong>app.rubis.pro</strong>) est édité par :
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Arthur Barré</strong>, entrepreneur individuel.</li>
|
||||
<li>Adresse : 8 rue Euthymènes, 13001 Marseille, France.</li>
|
||||
<li>SIRET : 952 196 442 00018.</li>
|
||||
<li>Numéro de TVA intracommunautaire : FR60952196442.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="hebergeur">2. Hébergement</h2>
|
||||
<p>
|
||||
<strong>Hébergeur</strong> : OVH SAS<br />
|
||||
<strong>Adresse</strong> : 2 rue Kellermann, 59100 Roubaix, France.
|
||||
</p>
|
||||
<p>L'ensemble de l'infrastructure et des données utilisateurs est situé en France.</p>
|
||||
|
||||
<h2 id="publication">3. Directeur de la publication</h2>
|
||||
<p>
|
||||
<strong>Arthur Barré</strong>, en sa qualité d'éditeur, est responsable de la publication du contenu du site et de l'application Rubis sur l'ongle.
|
||||
</p>
|
||||
|
||||
<h2 id="contact">4. Contact</h2>
|
||||
<div class="callout">
|
||||
<p>
|
||||
<strong>Email général</strong> :
|
||||
<a href="mailto:contact@rubis.pro">contact@rubis.pro</a>
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
Pour les demandes liées aux données personnelles ou pour exercer vos droits RGPD, consultez notre <a href="/confidentialite">politique de confidentialité</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="propriete">5. Propriété intellectuelle</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2 id="responsabilite">6. Limitation de responsabilité</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Concernant l'application Rubis, l'éditeur met à disposition un outil d'assistance à la relance automatisée. <strong>L'utilisateur reste seul responsable</strong> 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.
|
||||
</p>
|
||||
|
||||
<h2 id="liens">7. Liens externes</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2 id="cookies">8. Cookies</h2>
|
||||
<p>
|
||||
Le site landing <em>rubis.pro</em> ne dépose aucun cookie de mesure d'audience ni de traçage publicitaire. L'application <em>app.rubis.pro</em> utilise uniquement des cookies <strong>strictement nécessaires</strong> au fonctionnement (session d'authentification, refresh tokens). Les détails sont décrits dans la <a href="/confidentialite">politique de confidentialité</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="droit">9. Droit applicable et juridiction</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
19
apps/landing/src/pages/robots.txt.ts
Normal file
@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
54
apps/landing/src/pages/sitemap.xml.ts
Normal file
@ -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 }) =>
|
||||
` <url>
|
||||
<loc>${SITE}${path}</loc>
|
||||
<changefreq>${changefreq}</changefreq>
|
||||
<priority>${priority}</priority>
|
||||
</url>`,
|
||||
).join("\n");
|
||||
|
||||
const postUrls = posts
|
||||
.map(
|
||||
(p) => ` <url>
|
||||
<loc>${SITE}/blog/${p.slug}</loc>${p.publishedAt ? `\n <lastmod>${p.publishedAt}</lastmod>` : ""}
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${staticUrls}
|
||||
${postUrls}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
};
|
||||
11
apps/landing/src/styles/app.css
Normal file
@ -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}";
|
||||
13
apps/landing/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Card } from "@rubis/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Chip } from "@/components/ui/Chip";
|
||||
import { Chip } from "@rubis/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 : `<div className={cn("p-4", maybeBig && "p-8")} />`
|
||||
* 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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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")({
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 :
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||