refactor(landing): mono-langue FR + quick wins optimisations conversion
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m1s

- Retire le système i18n EN (apps/landing/src/i18n/, pages /en/*) ajouté
  en 4f3417f. Source unique de copy dans src/copy.ts (FR uniquement).
  Switcher de langue retiré du header, sitemap nettoyé des hreflang.
- Header : micro-baseline « Logiciel de relance de factures impayées »
  sous le wordmark pour lever l'ambiguïté du nom (§1).
- CTA principal : « Lancer Rubis » → « Démarrer mon essai 14 jours »
  avec sous-texte sur Hero / FinalCTA / Pricing (§5).
- Essai 30 j → 14 j sur landing + CGV §6.2 (§3).
- Blog promu en nav primaire avec label « Ressources » (§6).

Doc d'arbitrage : docs/tech/landing-optimisations.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-18 09:46:42 +02:00
parent 254f65b5d7
commit cecbddc496
32 changed files with 340 additions and 1499 deletions

View File

@ -23,15 +23,6 @@ export default defineConfig({
adapter: node({
mode: "standalone",
}),
i18n: {
// FR par défaut, EN sous /en/* — `prefixDefaultLocale: false` garde
// les URLs FR canoniques à la racine pour ne pas casser le SEO existant.
defaultLocale: "fr",
locales: ["fr", "en"],
routing: {
prefixDefaultLocale: false,
},
},
integrations: [
react(),
],

View File

@ -1,44 +1,37 @@
import { Brand } from "@rubis/ui";
import { getTranslations, type Locale } from "../i18n";
import { copy } from "../copy";
const CURRENT_YEAR = new Date().getFullYear();
type SiteFooterProps = {
locale?: Locale;
};
/**
* Footer public commun à toutes les pages rubis.pro/*.
* Liens légaux + tagline. Pas de réseaux sociaux V1.
*/
export function SiteFooter({ locale = "fr" }: SiteFooterProps) {
const t = getTranslations(locale);
const prefix = locale === "fr" ? "" : "/en";
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">{t.footer.tagline}</p>
<p className="text-[13px] text-ink-3 max-w-md">{copy.footer.tagline}</p>
</div>
<nav aria-label={t.footer.linksAria} className="flex flex-wrap gap-x-6 gap-y-2 text-[13px]">
<a href={`${prefix}/blog`} className="text-ink-2 hover:text-rubis transition-colors">
{t.nav.blog}
<nav aria-label={copy.footer.linksAria} className="flex flex-wrap gap-x-6 gap-y-2 text-[13px]">
<a href="/blog" className="text-ink-2 hover:text-rubis transition-colors">
{copy.nav.blog}
</a>
<a href={`${prefix}/changelog`} className="text-ink-2 hover:text-rubis transition-colors">
{t.nav.changelog}
<a href="/changelog" className="text-ink-2 hover:text-rubis transition-colors">
{copy.nav.changelog}
</a>
<a href={`${prefix}/mentions-legales`} className="text-ink-2 hover:text-rubis transition-colors">
{t.nav.legal}
<a href="/mentions-legales" className="text-ink-2 hover:text-rubis transition-colors">
{copy.nav.legal}
</a>
<a href={`${prefix}/confidentialite`} className="text-ink-2 hover:text-rubis transition-colors">
{t.nav.privacy}
<a href="/confidentialite" className="text-ink-2 hover:text-rubis transition-colors">
{copy.nav.privacy}
</a>
<a href={`${prefix}/cgv`} className="text-ink-2 hover:text-rubis transition-colors">
{t.nav.cgv}
<a href="/cgv" className="text-ink-2 hover:text-rubis transition-colors">
{copy.nav.cgv}
</a>
<a
href="mailto:contact@rubis.pro"
@ -50,7 +43,7 @@ export function SiteFooter({ locale = "fr" }: SiteFooterProps) {
</div>
<div className="mt-8 pt-6 border-t border-line text-[12px] text-ink-3">
© {CURRENT_YEAR} Rubis sur l'ongle. {t.footer.rights}
© {CURRENT_YEAR} Rubis sur l'ongle. {copy.footer.rights}
</div>
</div>
</footer>

View File

@ -1,5 +1,5 @@
import { Brand, Button, cn } from "@rubis/ui";
import { getTranslations, getAlternateUrl, type Locale } from "../i18n";
import { copy } from "../copy";
const APP_URL = "https://app.rubis.pro";
@ -7,29 +7,12 @@ 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;
locale?: Locale;
/** Path courant — sert à construire le lien switcher vers la locale alternative. */
currentPath?: string;
};
/**
* Header public commun à toutes les pages rubis.pro/*.
* Reçoit la locale via Layout.astro, expose un switcher FR/EN qui préserve
* la page courante.
*/
export function SiteHeader({
solid = false,
className,
locale = "fr",
currentPath = "/",
}: SiteHeaderProps) {
const t = getTranslations(locale);
const target: Locale = locale === "fr" ? "en" : "fr";
const altUrl = getAlternateUrl(currentPath, target);
const homeHref = locale === "fr" ? "/" : "/en/";
const pricingHref = locale === "fr" ? "/#pricing" : "/en/#pricing";
const blogHref = locale === "fr" ? "/blog" : "/en/blog";
export function SiteHeader({ solid = false, className }: SiteHeaderProps) {
return (
<header
className={cn(
@ -41,33 +24,31 @@ export function SiteHeader({
)}
>
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 h-[68px] flex items-center justify-between gap-6">
<a href={homeHref} className="flex items-center hover:no-underline">
<a href="/" className="flex items-center gap-3 hover:no-underline">
<Brand withSuffix gemSize={26} />
<span
className="hidden md:inline-block text-[12px] leading-tight text-ink-3 border-l border-line pl-3 max-w-[180px]"
aria-hidden
>
{copy.nav.baseline}
</span>
</a>
<nav aria-label={t.nav.langLabel} className="flex items-center gap-1.5 sm:gap-3">
<nav className="flex items-center gap-1.5 sm:gap-3">
<a
href={pricingHref}
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"
>
{t.nav.pricing}
{copy.nav.pricing}
</a>
<a
href={blogHref}
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"
>
{t.nav.blog}
</a>
<a
href={altUrl}
className="hidden sm:inline-flex h-10 items-center px-3 text-[13px] font-medium text-ink-3 hover:text-rubis transition-colors"
aria-label={t.nav.langLabel}
hrefLang={target}
>
{t.nav.langSwitch}
{copy.nav.blog}
</a>
<Button asChild size="sm">
<a href={APP_URL}>{t.nav.cta}</a>
<a href={APP_URL}>{copy.nav.cta}</a>
</Button>
</nav>
</div>

View File

@ -1,12 +1,8 @@
import { CheckCircle2, ShieldCheck, Sparkles, Building2 } from "lucide-react";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
type AutoBankingProps = {
locale?: Locale;
};
export function AutoBanking({ locale = "fr" }: AutoBankingProps) {
const t = getTranslations(locale).autoBanking;
export function AutoBanking() {
const t = copy.autoBanking;
return (
<section id="auto-banking" className="bg-cream">
@ -62,7 +58,7 @@ export function AutoBanking({ locale = "fr" }: AutoBankingProps) {
</div>
<div className="lg:justify-self-end w-full max-w-[460px]">
<DetectedPaymentMock locale={locale} />
<DetectedPaymentMock />
</div>
</div>
</div>
@ -84,9 +80,9 @@ function Benefit({ children }: { children: React.ReactNode }) {
);
}
function DetectedPaymentMock({ locale }: { locale: Locale }) {
const t = getTranslations(locale).autoBanking;
const amount = locale === "fr" ? "4 189,40 €" : "€4,189.40";
function DetectedPaymentMock() {
const t = copy.autoBanking;
const amount = "4 189,40 €";
return (
<div className="relative">
<div className="absolute -inset-4 bg-rubis-glow/40 blur-2xl rounded-card opacity-60" aria-hidden="true" />

View File

@ -1,12 +1,8 @@
import { Eyebrow } from "@rubis/ui";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
type FAQProps = {
locale?: Locale;
};
export function FAQ({ locale = "fr" }: FAQProps) {
const t = getTranslations(locale).faq;
export function FAQ() {
const t = copy.faq;
return (
<section id="faq" className="bg-cream-2 border-y border-line">

View File

@ -1,14 +1,10 @@
import { Button } from "@rubis/ui";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
const APP_URL = "https://app.rubis.pro";
type FinalCTAProps = {
locale?: Locale;
};
export function FinalCTA({ locale = "fr" }: FinalCTAProps) {
const t = getTranslations(locale).finalCta;
export function FinalCTA() {
const t = copy.finalCta;
return (
<section id="lancer">
@ -24,7 +20,8 @@ export function FinalCTA({ locale = "fr" }: FinalCTAProps) {
<a href={APP_URL}>{t.cta}</a>
</Button>
</div>
<p className="mt-5 text-[13px] text-ink-3">{t.hint}</p>
<p className="mt-4 text-[13px] text-ink-3">{t.ctaHint}</p>
<p className="mt-2 text-[13px] text-ink-3">{t.hint}</p>
</div>
</section>
);

View File

@ -1,11 +1,7 @@
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
type FootnotesProps = {
locale?: Locale;
};
export function Footnotes({ locale = "fr" }: FootnotesProps) {
const t = getTranslations(locale).footnotes;
export function Footnotes() {
const t = copy.footnotes;
return (
<aside className="border-t border-line bg-cream">

View File

@ -1,12 +1,8 @@
import { Eyebrow, Gem } from "@rubis/ui";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
type GamificationProps = {
locale?: Locale;
};
export function Gamification({ locale = "fr" }: GamificationProps) {
const t = getTranslations(locale).gamification;
export function Gamification() {
const t = copy.gamification;
return (
<section>

View File

@ -1,16 +1,12 @@
import { Brand, Button, Eyebrow, Gem, cn } from "@rubis/ui";
import { Check } from "lucide-react";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
const APP_URL = "https://app.rubis.pro";
type HeroProps = {
locale?: Locale;
};
export function Hero({ locale = "fr" }: HeroProps) {
const t = getTranslations(locale).hero;
const pricingHref = locale === "fr" ? "#pricing" : "/en/#pricing";
export function Hero() {
const t = copy.hero;
const pricingHref = "#pricing";
return (
<section className="relative overflow-hidden">
@ -48,7 +44,9 @@ export function Hero({ locale = "fr" }: HeroProps) {
</Button>
</div>
<div className="mt-7 flex flex-wrap items-center gap-x-3 gap-y-2 text-[13px] text-ink-3">
<p className="mt-3 text-[13px] text-ink-3 max-w-[480px]">{t.ctaPrimaryHint}</p>
<div className="mt-6 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 />
{t.feature1}
@ -98,7 +96,7 @@ export function Hero({ locale = "fr" }: HeroProps) {
{t.mockKpiCollected}
</div>
<div className="mt-1.5 font-display font-bold text-[22px] tracking-[-0.015em] text-ink tabular-nums">
{locale === "fr" ? "14 320 €" : "€14,320"}
14 320
</div>
<div className="mt-1 text-[11.5px] text-rubis font-medium">
{t.mockKpiCollectedDelta}
@ -109,7 +107,7 @@ export function Hero({ locale = "fr" }: HeroProps) {
{t.mockKpiDso}
</div>
<div className="mt-1.5 font-display font-bold text-[22px] tracking-[-0.015em] text-ink tabular-nums">
{locale === "fr" ? "38 j" : "38 d"}
38 j
</div>
<div className="mt-1 text-[11.5px] text-rubis font-medium">
{t.mockKpiDsoDelta}

View File

@ -1,12 +1,10 @@
import { Eyebrow, cn } from "@rubis/ui";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
type HowItWorksProps = {
locale?: Locale;
};
type HowDict = typeof copy.how;
export function HowItWorks({ locale = "fr" }: HowItWorksProps) {
const t = getTranslations(locale).how;
export function HowItWorks() {
const t = copy.how;
return (
<section id="how" className="bg-cream-2 border-y border-line">
@ -105,8 +103,6 @@ function Step({ num, title, body, flip = false, children, stepLabel }: StepProps
);
}
type HowDict = ReturnType<typeof getTranslations>["how"];
/* ============== Widget 01 — Dropzone ============== */
function DropzoneWidget({ fileLabel }: { fileLabel: string }) {
return (

View File

@ -1,19 +1,15 @@
import { Eyebrow } from "@rubis/ui";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
const SANCTIONED = [
["Fnac Darty", "3,9 M€", "€3.9 m"],
["Cdiscount", "2,1 M€", "€2.1 m"],
["Sanofi", "1,65 M€", "€1.65 m"],
["LCL", "1,5 M€", "€1.5 m"],
["Fnac Darty", "3,9 M€"],
["Cdiscount", "2,1 M€"],
["Sanofi", "1,65 M€"],
["LCL", "1,5 M€"],
] as const;
type LegalProps = {
locale?: Locale;
};
export function Legal({ locale = "fr" }: LegalProps) {
const t = getTranslations(locale).legal;
export function Legal() {
const t = copy.legal;
return (
<section className="bg-cream-2 border-y border-line">
@ -56,11 +52,11 @@ export function Legal({ locale = "fr" }: LegalProps) {
<p className="mt-2 text-[15px] text-ink-2 leading-snug">{t.clipCaption}</p>
<ul className="mt-5 divide-y divide-line">
{SANCTIONED.map(([name, amountFr, amountEn]) => (
{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">
{locale === "fr" ? amountFr : amountEn}
{amount}
</span>
</li>
))}

View File

@ -1,15 +1,11 @@
import { Button, Eyebrow } from "@rubis/ui";
import { Check } from "lucide-react";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
const APP_URL = "https://app.rubis.pro";
type PricingProps = {
locale?: Locale;
};
export function Pricing({ locale = "fr" }: PricingProps) {
const t = getTranslations(locale).pricing;
export function Pricing() {
const t = copy.pricing;
return (
<section id="pricing">
@ -34,7 +30,7 @@ export function Pricing({ locale = "fr" }: PricingProps) {
</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">
{locale === "fr" ? "19 €" : "€19"}
19
</span>
<span className="font-sans text-[14px] text-ink-3 leading-tight whitespace-pre-line">
{t.proPriceUnit}
@ -106,7 +102,7 @@ export function Pricing({ locale = "fr" }: PricingProps) {
<b className="text-ink">{t.businessBody_bold2}</b>
{t.businessBody_c}
<code className="bg-cream-2 px-1.5 py-0.5 rounded text-[12.5px]">
{locale === "fr" ? "compta@votre-entreprise.fr" : "accounts@your-company.com"}
compta@votre-entreprise.fr
</code>
{t.businessBody_d}
</PricingAside>

View File

@ -1,12 +1,8 @@
import { Eyebrow } from "@rubis/ui";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
type PromiseProps = {
locale?: Locale;
};
export function Promise({ locale = "fr" }: PromiseProps) {
const t = getTranslations(locale).promise;
export function Promise() {
const t = copy.promise;
return (
<section>

View File

@ -1,12 +1,8 @@
import { Eyebrow } from "@rubis/ui";
import { getTranslations, type Locale } from "../../i18n";
import { copy } from "../../copy";
type StatsProps = {
locale?: Locale;
};
export function Stats({ locale = "fr" }: StatsProps) {
const t = getTranslations(locale).stats;
export function Stats() {
const t = copy.stats;
const items = [
{ num: t.item1Num, desc: t.item1Desc, source: t.item1Source },
{ num: t.item2Num, desc: t.item2Desc, source: t.item2Source },

View File

@ -1,17 +1,16 @@
/**
* Dictionnaire FR source de vérité pour les types du module i18n.
* Source de vérité pour les chaînes de la landing rubis.pro.
*
* Convention : clés en kebab-case, regroupées par surface (header, footer, hero,
* etc.). Les phrases avec inline-HTML utilisent {{tag}} pour les emphases et
* sont rendues via le helper `tHTML()` (escape par défaut, ouvre uniquement
* la balise dont la clé matche).
* Le site est mono-langue (FR). Plus de système i18n : on importe `copy`
* directement dans les composants. Les phrases avec inline-HTML utilisent
* la convention `_a`/`_b`/`_em` pour permettre l'emphase dans le rendu.
*/
export const fr = {
export const copy = {
meta: {
home: {
title: "Vos factures relancées toutes seules pendant que vous travaillez",
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.",
"Le SaaS de relance de factures impayées pour TPE-PME françaises. Drag-and-drop, OCR, plans de relance automatiques. 14 jours d'essai gratuit, sans engagement.",
},
cgv: {
title: "Conditions générales de vente — Rubis",
@ -36,14 +35,13 @@ export const fr = {
},
nav: {
pricing: "Tarifs",
blog: "Blog",
blog: "Ressources",
changelog: "Changelog",
legal: "Mentions légales",
privacy: "Confidentialité",
cgv: "CGV",
cta: "Essai gratuit 30 j",
langSwitch: "English",
langLabel: "Langue",
cta: "Essai 14 j",
baseline: "Logiciel de relance de factures impayées",
},
footer: {
tagline: "Le SaaS de relance de factures impayées pour TPE-PME françaises. Fait à Paris, avec du temps libéré.",
@ -58,9 +56,10 @@ export const fr = {
body_a: "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 ",
body_bold: "5 heures par semaine",
body_c: ".",
ctaPrimary: "Lancer Rubis →",
ctaPrimary: "Démarrer mon essai 14 jours →",
ctaPrimaryHint: "Première relance envoyée en 5 minutes · 14 jours sans engagement",
ctaSecondary: "Voir les tarifs",
feature1: "30 jours gratuits puis Free 5 factures",
feature1: "14 jours gratuits puis Free 5 factures",
feature2: "Hébergement souverain",
feature3: "Made in France 🇫🇷",
mockDashboard: "Tableau de bord",
@ -231,7 +230,7 @@ export const fr = {
proBody_a: "Pour ce prix, vous avez Rubis dans son ",
proBody_bold: "intégralité",
proBody_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.",
proCta: "Commencer l'essai 30 jours →",
proCta: "Démarrer mon essai 14 jours →",
proCtaHint: "Sans engagement, annulable à tout moment",
included: "Ce qui est inclus",
features: [
@ -324,8 +323,9 @@ export const fr = {
},
finalCta: {
title: "Récupérez vos premières heures dès aujourd'hui.",
body: "30 jours gratuits, puis le plan Free continue avec 5 factures actives. Pas de carte demandée pour démarrer.",
cta: "Lancer Rubis →",
body: "14 jours gratuits, puis le plan Free continue avec 5 factures actives. Pas de carte demandée pour démarrer.",
cta: "Démarrer mon essai 14 jours →",
ctaHint: "Première relance envoyée en 5 minutes · 14 jours sans engagement",
hint: "Inscription en 30 secondes. Annulation 1-clic à tout moment.",
},
footnotes: {
@ -373,5 +373,3 @@ export const fr = {
},
},
};
export type Dict = typeof fr;

View File

@ -1,381 +0,0 @@
import type { Dict } from "./fr";
export const en: Dict = {
meta: {
home: {
title: "Your invoices chased automatically while you focus on your work",
description:
"The unpaid-invoice chaser SaaS built for French small businesses. Drag-and-drop, OCR, automated reminder plans. 30 days free, no credit card.",
},
cgv: {
title: "Terms of Service — Rubis",
description: "Terms of service for the Rubis sur l'ongle service.",
},
privacy: {
title: "Privacy Policy — Rubis",
description: "Privacy policy and personal-data handling at Rubis sur l'ongle.",
},
legal: {
title: "Legal Notice — Rubis",
description: "Legal notice for rubis.pro and its publisher Rubis sur l'ongle.",
},
blog: {
title: "Rubis Blog — cash-flow tips for small businesses",
description:
"Advice, hands-on stories and benchmarks on invoice collection and cash-flow management for small businesses.",
},
changelog: {
title: "Rubis Changelog",
description: "All the new features, improvements and fixes shipped on Rubis sur l'ongle.",
},
},
nav: {
pricing: "Pricing",
blog: "Blog",
changelog: "Changelog",
legal: "Legal notice",
privacy: "Privacy",
cgv: "Terms",
cta: "Free 30-day trial",
langSwitch: "Français",
langLabel: "Language",
},
footer: {
tagline: "The unpaid-invoice chaser SaaS built for small businesses. Made in Paris, with the hours we freed up.",
rights: "All rights reserved.",
linksAria: "Useful links",
},
hero: {
eyebrow: "The invoice-chasing tool for small businesses",
title_a: "Your invoices chased ",
title_b: "on their own",
title_c: " while you keep working.",
body_a:
"The unpaid-invoice chasing app built for small businesses. Drag-and-drop your invoices, pick a chase plan, forget about them. Rubis sends, follows up, chases — and you save on average ",
body_bold: "5 hours per week",
body_c: ".",
ctaPrimary: "Start Rubis →",
ctaSecondary: "See pricing",
feature1: "30 days free, then Free plan with 5 invoices",
feature2: "Sovereign hosting",
feature3: "Made in France 🇫🇷",
mockDashboard: "Dashboard",
mockRubis: "rubies",
mockHoursPrefix: "≈ ",
mockHoursValue: "24h 48m",
mockHoursSuffix: " you didn't spend chasing invoices.",
mockKpiCollected: "Collected",
mockKpiCollectedDelta: "+ €2,800 vs April",
mockKpiDso: "DSO*",
mockKpiDsoDelta: "↘ 6 d since Rubis",
mockToday: "Today",
mockActivity1Pre: "📤 Reminder sent to ",
mockActivity1Name: "Atelier Durand",
mockActivity2Pre: "✓ Invoice ",
mockActivity2Name: "F-2024-0035",
mockActivity2Post: " paid",
mockActivity3: "📥 3 invoices imported and OCR'd",
mockBadge: "~3 minutes in the morning",
},
stats: {
eyebrow: "The state of payments in France",
title: "Three outrageous numbers.",
subtitle: "And you're probably part of these surveys.",
item1Num: "44 d",
item1Desc: "The average payment delay on an invoice issued by a small French business.",
item1Source: "Source: Altares · Observatoire des délais de paiement, 2024",
item2Num: "€15 bn",
item2Desc: "Of cash locked up at French SMBs because of payment delays.",
item2Source: "Source: Banque de France · Annual report 2024",
item3Num: "26 %",
item3Desc: "Chance of getting paid if you wait more than 30 days to chase.",
item3Source: "Source: AFDCC · Credit management study",
},
promise: {
eyebrow: "Our belief",
title_a: "Your time is ",
title_em: "more valuable",
title_b: ".",
p1: "You didn't start your business to spend your days writing polite reminder emails. While you're typing « just a quick reminder about… », you're not invoicing, you're not selling, you're not creating.",
p2_a: "SMBs that automate their chasing go from ",
p2_bold1: "8 hours a week",
p2_b: " to ",
p2_bold2: "less than 3",
p2_c: ". That's 5 hours of your life back. Every week. Forever.",
p3: "Sometimes less, if your default plan is well dialed in.",
box: {
title: "Your time in numbers",
row1Label: "Hours lost per week",
row1Value: "5 h",
row2Label: "Per month",
row2Value: "~ 21 h",
row3Label: "At €50/h billed",
row3Value: "€1,050",
totalLabel: "Annual cost of manual chasing",
totalValue: "€12,600",
},
},
how: {
eyebrow: "How it works",
title: "Four steps. That's it.",
subtitle: "Really. Sometimes three, if the client pays on the first reminder.",
stepLabel: "Step",
step1Title: "You import your invoices.",
step1Body:
"PDF, photo from your phone, scan received by email — anything goes. The OCR* reads it, extracts amount, client and due date. You double-check. Twenty seconds per invoice, no more.",
step2Title: "You pick a chase plan.",
step2BodyP1:
"We've packaged several — for example a B2B standard (D+3, D+10, D+20), tuning the tone to the due date and the client's history.",
step2BodyP2: "And of course, you can build your own custom plans.",
step3Title: "You confirm. The machine handles the rest.",
step3Body:
"While you're working, Rubis sends emails at the planned time, tracks who opened, who didn't reply, and before each reminder it quietly emails you: \"Has this invoice been paid?\". You answer in two seconds. The algorithm does the rest.",
step4Title: "Payment lands. Your client gets thanked.",
step4Body:
"When you mark « Paid », Rubis automatically sends a short note to your client: \"Thank you, payment received\". It's optional, configurable — but most people turn it on. Because a thanked client is a returning client.",
step4Reward: "The reward: your rubies counter ticks up. Quietly. Like a steady stream of good news.",
widgetFile: "invoice-2024-0042.pdf",
widgetMonth: "May 2026",
widgetWeekdays: "M,T,W,T,F,S,S",
widgetCalLegend: "Scheduled reminders",
widgetCalMeta: "3 steps · B2B plan",
widgetEmailSender: "Studio Lumière",
widgetEmailWhen: "2 min ago",
widgetEmailTo: "To: ",
widgetEmailToAddr: "client@boulangerie-paul.fr",
widgetEmailSubject: "Thank you, payment received",
widgetEmailBody_a: "Hi, we confirm receipt of your payment of ",
widgetEmailBody_b: ". Have a great day — talk soon.",
widgetEmailAuto: "Sent automatically",
widgetAssistantQ: "Has this invoice been paid?",
widgetAssistantYes: "✓ Yes",
widgetAssistantNotYet: "Not yet",
widgetAssistantHint: "Two seconds, and the machine takes care of the rest.",
},
gamification: {
eyebrow: "The currency of time saved",
title: "1 ruby = 10 minutes of your life.",
body: "For every reminder Rubis sends on your behalf, you earn a ruby. At the end of the month, you see exactly how many hours you got back. Not a DSO* chart. Time. Concrete.",
counterUnit: "rubies",
counterApprox_a: "≈ ",
counterApprox_b: "24h 48m",
counterApprox_c: " of your month",
footer_a: "Top users free up as much as ",
footer_bold: "30 hours per month",
footer_b: ".",
},
autoBanking: {
badge: "Coming soon",
title_a: "Never reply ",
title_em: "« It's paid »",
title_b: " again.",
body: "Connect your bank account in read-only mode. Rubis automatically detects your clients' transfers, marks invoices as paid and sends the thank-you note. You don't reply to a thing.",
benefit1_bold: "Real-time detection",
benefit1_rest: " via Powens (AISP-licensed by the ACPR).",
benefit2_bold: "All French banks",
benefit2_rest: " — pro or personal, neo or traditional.",
benefit3_bold: "Review-mode or autopilot",
benefit3_rest: " — you choose whether Rubis waits for your OK or marks paid on its own.",
benefit4_bold: "Read-only.",
benefit4_rest: " No money can ever be moved, ever. Revocable in one click.",
included_a: "Included on the ",
included_plan1: "Pro",
included_b: " and ",
included_plan2: "Business",
compliance: "DSP2-compliant · AISP licensing in final review",
mockSender: "Rubis sur l'ongle",
mockWhen: "1 min ago",
mockTo: "To: ",
mockToAddr: "you@your-business.com",
mockTitle_a: "Garage Lemoine paid ",
mockTitle_b: "F2026-0013",
mockSubtitle: "Detected automatically on your Pro account",
mockRowClient: "Client",
mockRowClientName: "Garage Lemoine",
mockRowInvoice: "Invoice",
mockRowAmount: "Amount",
mockAction1: "Invoice marked paid",
mockAction2: "Reminders cancelled",
mockAction3: "Thank-you sent to client",
mockBadge: "+1 ruby · you did nothing",
},
legal: {
eyebrow: "You're within your rights",
title: "The law is on your side. We just spare you from waving it around.",
p1_a: "The ",
p1_lme: "LME law",
p1_b: " caps B2B payment terms at ",
p1_bold1: "60 days",
p1_c: " (or 45 days end of month). Penalties can reach ",
p1_bold2: "€2 million",
p1_d: ". In 2025, the French Senate unanimously voted to tighten the rules further.",
p2_a: "But you don't want to send a court bailiff to your best client. Rubis does the middle work — pro, courteous, well-spaced reminders. The tone ramps up gradually, never all at once. And you stay in full control: the formal notice is sent by ",
p2_bold: "you",
p2_b: ", on manual confirmation.",
clipHeader: "DGCCRF fines",
clipYear: "2025",
clipBigNum: "€47 m",
clipCaption: "in penalties handed out to bad French payers last year.",
clipMore: "… and 405 other companies audited",
clipSource: "Source · DGCCRF, 2025 annual report · economie.gouv.fr",
},
pricing: {
eyebrow: "Pricing",
title: "Cheaper than one hour of your time per month.",
subtitle:
"Straight to the point. One main plan we recommend to 99% of you, and two options on the sides. That's it.",
proBadge: "The plan we recommend",
proName_a: "The ",
proName_em: "Pro",
proName_b: " plan.",
proPriceUnit: "per month\nexcl. tax",
proBody_a: "For this price, you get Rubis in ",
proBody_bold: "full",
proBody_b:
". Unlimited invoices and OCR, custom chase plans, detailed stats, priority support. No hidden tier, no usage surcharge.",
proCta: "Start 30-day trial →",
proCtaHint: "No commitment, cancel anytime",
included: "What's included",
features: [
["Unlimited invoices.", "Whether you send 10 or 500 per month, same price."],
["Unlimited OCR.", "Drag & drop, mobile photo, batches of 20 at once."],
[
"Reminders signed in your name.",
"Your clients see your name and reply directly to your email. No mention of Rubis.",
],
["Customisable plans", "with variables and bespoke tones."],
[
"Automatic bank detection",
"coming soon: connect your bank (read-only, AISP), Rubis marks invoices paid on its own.",
],
["Detailed stats", "+ CSV export for your accountant."],
["Priority support.", "Reply within 4 business hours, from a human in France."],
["Mobile and desktop app,", "French hosting."],
],
or: "— or —",
perMonth: "/month",
startCta: "Start",
startCtaSuffix: "now",
freeName: "Free plan",
freePrice: "€0",
freeQualifier: "To try it out or get started solo",
freeBody_a: "The Free plan runs Rubis on ",
freeBody_bold: "5 active invoices",
freeBody_b: " at all times. Free, for good. Our way of proving the promise holds.",
businessName: "Business plan",
businessPrice: "€49",
businessQualifier: "For teams & demanding pros",
businessBody_a: "Everything in Pro, plus: ",
businessBody_bold1: "up to 5 teammates",
businessBody_b: " in the company, each with their own access. Your reminders go out from ",
businessBody_bold2: "your real pro address",
businessBody_c: " (",
businessBody_d:
"), not a Rubis address — your clients never notice and your emails land better in the primary inbox.",
closing: "No hidden tier. No usage surcharge. One-click cancellation, no questions asked.",
},
faq: {
eyebrow: "Frequently asked",
title: "You're probably wondering…",
items: [
{
q: "What if my client pays off-platform — how does Rubis know?",
a_html:
"Before each reminder, Rubis sends you a quick email: <i>« Have you been paid for invoice F-2024-0042? »</i> with two buttons. You click « Yes » in 3 seconds, the plan stops. You click « No » (or don't reply), the reminder goes out as planned. You configure the cadence and timing of these check-ins in your plans.<br><br><b>Soon</b>, you'll also be able to connect your bank account in read-only mode: Rubis will detect incoming transfers automatically and mark the invoice paid without asking. See the <a href=\"#auto-banking\" class=\"text-rubis underline underline-offset-2 hover:no-underline\">Automatic mode</a> section above.",
},
{
q: "Is the bank connection safe? Can you move my money?",
a_html:
"<b>No, and it's technically impossible.</b> The bank connection goes through <b>Powens</b>, an AISP provider licensed by the ACPR (the French banking regulator). The AISP status, defined by the European <abbr title=\"Payment Services Directive 2\">PSD2</abbr>, only authorises <b>read access</b> to accounts and transactions — <i>never</i> payment initiation or fund movement.<br><br>Concretely: Rubis reads the list of your incoming transfers to match them to your invoices. No outbound action is possible. You revoke access in one click from your Settings, and Powens immediately cuts the read access.",
},
{
q: "Do my invoices and data stay private?",
a_html:
"Of course. French hosting, GDPR-compliant. Your PDFs are stored encrypted. No data is shared with third parties. You can export or delete your data at any time.",
},
{
q: "Can I customise email content?",
a_html:
"Yes, from the Pro plan onward. All emails are templates with variables (<code class=\"bg-cream-2 px-1.5 py-0.5 rounded text-[13px]\">{{client_first_name}}</code>, <code class=\"bg-cream-2 px-1.5 py-0.5 rounded text-[13px]\">{{invoice_number}}</code>, <code class=\"bg-cream-2 px-1.5 py-0.5 rounded text-[13px]\">{{amount}}</code>…). You can rewrite each step, tune the tone, add your email signature and your logo.",
},
{
q: "Will my clients know I use Rubis?",
a_html:
"Not really. On the <b>Pro</b> plan, your clients see <b>your name</b> as the sender, and when they click « Reply », their message lands directly in <b>your inbox</b>. No footer mentions Rubis. Enough for 95% of freelancers and small businesses.<br><br>On the <b>Business</b> plan we go further: your emails actually go out from <b>your own address</b> (<code class=\"bg-cream-2 px-1.5 py-0.5 rounded text-[13px]\">accounts@your-company.com</code>). No one can tell you're using a tool, and your reminders land <b>in the primary inbox</b> rather than spam or promotions — typically a 1015% lift on open rate.",
},
{
q: "What if I want to chase manually, without a plan?",
a_html:
"Always possible. On any invoice, there's a « Chase now » button that sends an email immediately. Handy when you just got off the phone with a client who asked for a summary.",
},
{
q: "Does the formal notice go out automatically?",
a_html:
"No. Never. It's a strong product decision: a formal notice has significant legal and relational consequences. Rubis drafts it at the planned step of your plan, notifies you, and <b>you</b> click « Send » on a confirmation modal. You keep control over when the tone genuinely shifts.",
},
{
q: "How long does it take to get started?",
a_html:
"Sign-up in 30 seconds. Configure your email signature and first invoice in 5 minutes. The first reminder can go out right after. With a well-tuned default plan, creating a new invoice in chase mode takes <b>2 clicks</b>.",
},
{
q: "Why this « ruby » thing?",
a_html:
"Because accounting numbers (DSO, collection rate, AR aging) don't get anyone out of bed. Time saved does. <b>1 ruby = 10 minutes back</b> = 1 reminder you didn't have to write. At month's end you see « 124 rubies ≈ 24h 48m ». It's concrete. And way more fun than staring at line charts.",
},
],
},
finalCta: {
title: "Get your first hours back today.",
body: "30 days free, then the Free plan continues with 5 active invoices. No card required to start.",
cta: "Start Rubis →",
hint: "Sign-up in 30 seconds. One-click cancellation anytime.",
},
footnotes: {
ocr_bold: "OCR",
ocr_a: " — for ",
ocr_em: "Optical Character Recognition",
ocr_b:
". Automatic text recognition on a PDF or a photo. The machine reads your invoice over your shoulder, basically.",
dso_bold: "DSO",
dso_a: " — for ",
dso_em: "Days Sales Outstanding",
dso_b:
". The average number of days between issuing an invoice and collecting payment. The lower it is, the easier your cash flow breathes.",
},
blog: {
indexTitle: "The blog",
indexSubtitle:
"Advice, hands-on stories and benchmarks on invoice collection and cash-flow management for small businesses.",
empty: "No articles published yet. Check back soon!",
readMore: "Read article →",
publishedOn: "Published on",
backToBlog: "← Back to blog",
minutesRead: "min read",
},
changelog: {
title: "Changelog",
subtitle: "All the new features, improvements and fixes shipped on Rubis.",
rssLabel: "RSS feed",
types: {
major: "Major update",
minor: "Update",
patch: "Patch",
},
highlights: "What's inside",
},
legalPages: {
legal: {
title: "Legal notice",
backHome: "← Back to home",
},
privacy: {
title: "Privacy policy",
backHome: "← Back to home",
},
cgv: {
title: "Terms of service",
backHome: "← Back to home",
},
},
};

View File

@ -1,43 +0,0 @@
/**
* Module i18n landing. Le helper `getTranslations(locale)` retourne le
* dictionnaire complet pour la locale donnée, type-safe via la shape FR.
*/
import { fr } from "./fr";
import { en } from "./en";
import { DEFAULT_LOCALE, type Locale, isLocale } from "./types";
const DICTS = { fr, en } as const;
export function getTranslations(locale: Locale) {
return DICTS[locale];
}
/**
* Résout la locale courante depuis un `Astro.currentLocale` (typé `string |
* undefined` côté Astro 6). Fallback sur la locale par défaut.
*/
export function resolveLocale(input: unknown): Locale {
return isLocale(input) ? input : DEFAULT_LOCALE;
}
/**
* Construit l'URL alternative pour le language switcher. Étant donné la
* locale courante et le pathname, retourne l'URL vers l'autre locale en
* préservant la page courante.
*
* / + en /en/
* /blog + en /en/blog
* /en/blog + fr /blog
*/
export function getAlternateUrl(currentPathname: string, targetLocale: Locale): string {
// Strip leading /en if present (route FR canonique)
const stripped = currentPathname.replace(/^\/en(?=\/|$)/, "") || "/";
if (targetLocale === DEFAULT_LOCALE) {
return stripped;
}
// Préfixer /en
return stripped === "/" ? "/en/" : `/en${stripped}`;
}
export { DEFAULT_LOCALE, isLocale } from "./types";
export type { Locale } from "./types";

View File

@ -1,15 +0,0 @@
/**
* Type-safe i18n pour la landing rubis.pro.
*
* Le dictionnaire FR fait foi : `Dict` est inféré de fr.ts et `Locale` énumère
* les locales supportées. EN doit matcher la même shape (TypeScript le force).
*/
export type Locale = "fr" | "en";
export const LOCALES: readonly Locale[] = ["fr", "en"] as const;
export const DEFAULT_LOCALE: Locale = "fr";
export function isLocale(value: unknown): value is Locale {
return typeof value === "string" && (LOCALES as readonly string[]).includes(value);
}

View File

@ -14,7 +14,6 @@ import "../styles/app.css";
import { SiteHeader } from "../components/SiteHeader";
import { SiteFooter } from "../components/SiteFooter";
import PostHog from "../components/posthog.astro";
import { resolveLocale, getAlternateUrl, type Locale } from "../i18n";
/**
* URLs hashées (au build) des deux woff2 latin que la quasi-totalité du
@ -68,12 +67,7 @@ const {
jsonLd,
} = Astro.props;
const locale: Locale = resolveLocale(Astro.currentLocale);
const currentPath = pathname ?? Astro.url.pathname;
const alternatePath = getAlternateUrl(currentPath, locale === "fr" ? "en" : "fr");
const htmlLang = locale === "en" ? "en" : "fr";
const ogLocale = locale === "en" ? "en_US" : "fr_FR";
const ogLocaleAlt = locale === "en" ? "fr_FR" : "en_US";
/**
* Suffixe brand intelligent :
@ -90,13 +84,12 @@ const fullTitle =
const resolvedOgImage = ogImage ?? DEFAULT_OG_IMAGE;
const url = `${SITE_URL}${currentPath}`;
const alternateUrl = `${SITE_URL}${alternatePath}`;
const robots = noindex ? "noindex,nofollow" : "index,follow,max-image-preview:large";
const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
---
<!doctype html>
<html lang={htmlLang}>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@ -107,14 +100,10 @@ const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<meta name="author" content="Rubis sur l'ongle" />
<link rel="canonical" href={url} />
<link rel="alternate" hreflang={locale === "fr" ? "fr-FR" : "en"} href={url} />
<link rel="alternate" hreflang={locale === "fr" ? "en" : "fr-FR"} href={alternateUrl} />
<link rel="alternate" hreflang="x-default" href={locale === "fr" ? url : alternateUrl} />
{/* Open Graph */}
<meta property="og:site_name" content="Rubis sur l'ongle" />
<meta property="og:locale" content={ogLocale} />
<meta property="og:locale:alternate" content={ogLocaleAlt} />
<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} />
@ -164,10 +153,10 @@ const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<PostHog />
</head>
<body>
<SiteHeader solid={solidHeader} locale={locale} currentPath={currentPath} />
<SiteHeader solid={solidHeader} />
<main>
<slot />
</main>
<SiteFooter locale={locale} />
<SiteFooter />
</body>
</html>

View File

@ -130,8 +130,8 @@ Astro.response.headers.set(
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.
Rubis automatise vos relances avec la bonne tonalité, au bon moment. 14 jours
d'essai, sans engagement.
</p>
<a
href="https://app.rubis.pro"

View File

@ -9,14 +9,12 @@
import Layout from "../../layouts/Layout.astro";
import { PostCard } from "../../components/blog/PostCard";
import { listPosts } from "../../lib/api";
import { resolveLocale, getTranslations } from "../../i18n";
import { copy } from "../../copy";
const locale = resolveLocale(Astro.currentLocale);
const t = getTranslations(locale);
const posts = await listPosts();
const title = t.meta.blog.title;
const description = t.meta.blog.description;
const title = copy.meta.blog.title;
const description = copy.meta.blog.description;
const jsonLd = {
"@context": "https://schema.org",
@ -56,15 +54,15 @@ Astro.response.headers.set(
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>
{locale === "fr" ? "Blog Rubis" : "Rubis Blog"}
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"
>
{t.blog.indexTitle}
{copy.blog.indexTitle}
</h1>
<p class="mt-5 max-w-[640px] mx-auto text-[18px] text-ink-2 leading-relaxed">
{t.blog.indexSubtitle}
{copy.blog.indexSubtitle}
</p>
</div>
</section>
@ -73,7 +71,7 @@ Astro.response.headers.set(
{
posts.length === 0 ? (
<div class="text-center py-16 text-ink-3">
<p>{t.blog.empty}</p>
<p>{copy.blog.empty}</p>
</div>
) : (
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">

View File

@ -81,7 +81,7 @@ import LegalLayout from "../layouts/LegalLayout.astro";
<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>
<p>Tout nouvel utilisateur bénéficie d'une période d'essai de <strong>14 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>

View File

@ -20,10 +20,7 @@ export const prerender = true;
import Layout from "../../layouts/Layout.astro";
import { getCollection, render } from "astro:content";
import { resolveLocale, getTranslations } from "../../i18n";
const locale = resolveLocale(Astro.currentLocale);
const t = getTranslations(locale);
import { copy } from "../../copy";
const entries = (await getCollection("changelog")).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
@ -36,23 +33,24 @@ const rendered = await Promise.all(
}),
);
const intlLocale = locale === "en" ? "en-US" : "fr-FR";
const dateLong = new Intl.DateTimeFormat(intlLocale, {
const dateLong = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
});
const dateShort = new Intl.DateTimeFormat(intlLocale, {
const dateShort = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
});
const typeLabel: Record<string, string> = locale === "en"
? { feature: "New", improvement: "Improvement", fix: "Fix" }
: { feature: "Nouveauté", improvement: "Amélioration", fix: "Correction" };
const typeLabel: Record<string, string> = {
feature: "Nouveauté",
improvement: "Amélioration",
fix: "Correction",
};
const title = t.meta.changelog.title;
const description = t.meta.changelog.description;
const title = copy.meta.changelog.title;
const description = copy.meta.changelog.description;
const jsonLd = {
"@context": "https://schema.org",
@ -81,13 +79,13 @@ const jsonLd = {
<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>
{locale === "en" ? "What's new" : "Tout ce qui change"}
Tout ce qui change
</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">
{t.changelog.title}
{copy.changelog.title}
</h1>
<p class="mt-5 max-w-[620px] mx-auto text-[18px] text-ink-2 leading-relaxed">
{t.changelog.subtitle}
{copy.changelog.subtitle}
</p>
<div class="mt-7 inline-flex items-center gap-4 text-[13px] text-ink-3">
<a
@ -109,7 +107,7 @@ const jsonLd = {
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
{locale === "en" ? "Subscribe to RSS feed" : "S'abonner au flux RSS"}
S'abonner au flux RSS
</a>
</div>
</div>
@ -123,7 +121,7 @@ const jsonLd = {
{
rendered.length === 0 ? (
<div class="text-center py-16 text-ink-3">
<p>{locale === "en" ? "No entries published yet." : "Aucune entrée publiée pour l'instant."}</p>
<p>Aucune entrée publiée pour l'instant.</p>
</div>
) : (
<ol class="space-y-12 lg:space-y-16 list-none p-0">
@ -148,7 +146,7 @@ const jsonLd = {
<a
href={`#${data.version}`}
class="inline-flex items-center gap-1.5 font-display font-extrabold text-[13px] text-rubis-deep bg-rubis-glow border border-rubis/15 px-3 py-1.5 rounded-full tracking-[-0.005em] hover:bg-rubis hover:text-cream transition-colors no-underline"
aria-label={`${locale === "en" ? "Direct link to version" : "Lien direct vers la version"} ${data.version}`}
aria-label={`Lien direct vers la version ${data.version}`}
>
v{data.version}
</a>
@ -205,10 +203,10 @@ const jsonLd = {
</main>
{/* Sticky rail — jump nav versions, desktop only */}
<aside class="hidden lg:block" aria-label={locale === "en" ? "Version navigation" : "Navigation des versions"}>
<aside class="hidden lg:block" aria-label="Navigation des versions">
<div class="sticky top-24">
<h2 class="text-[11px] uppercase tracking-[0.08em] font-semibold text-ink-3 mb-4">
{locale === "en" ? "Versions" : "Versions"}
Versions
</h2>
<nav class="rail flex flex-col gap-0.5 border-l border-line">
{

View File

@ -1,57 +0,0 @@
---
/**
* /en/blog — English blog index. Same data source as the FR blog (posts come
* from the API in their authoring language); UI chrome is translated.
*/
import Layout from "../../../layouts/Layout.astro";
import { PostCard } from "../../../components/blog/PostCard";
import { listPosts } from "../../../lib/api";
import { getTranslations } from "../../../i18n";
const locale = "en" as const;
const t = getTranslations(locale);
const posts = await listPosts();
const title = t.meta.blog.title;
const description = t.meta.blog.description;
Astro.response.headers.set(
"Cache-Control",
"public, max-age=300, stale-while-revalidate=86400",
);
---
<Layout title={title} description={description} solidHeader>
<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>
Rubis Blog
</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"
>
{t.blog.indexTitle}
</h1>
<p class="mt-5 max-w-[640px] mx-auto text-[18px] text-ink-2 leading-relaxed">
{t.blog.indexSubtitle}
</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>{t.blog.empty}</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>

View File

@ -1,76 +0,0 @@
---
export const prerender = true;
import LegalLayout from "../../layouts/LegalLayout.astro";
---
<LegalLayout
title="Terms of Service"
description="Terms of service for the Rubis sur l'ongle service."
eyebrow="Contractual terms"
h1={`Terms <em>of Service</em>`}
lede="The Rubis sur l'ongle service is operated under French law. Our official terms of service are written in French and govern any contractual relationship. The summary below is provided for convenience only — the French version prevails in case of any discrepancy."
lastUpdated="May 7, 2026"
>
<div class="callout">
<p>
<strong>English summary only.</strong> The binding version of these Terms
is the French version available at <a href="/cgv">/cgv</a>. By using the
Rubis service, you accept the French Terms of Service.
</p>
</div>
<h2 id="overview">1. Overview</h2>
<p>
Rubis sur l'ongle is a SaaS for chasing unpaid invoices, operated by
<strong>Arthur Barré</strong>, sole trader registered in France (SIRET
952 196 442 00018, VAT FR60952196442). The service is offered to
professional users (freelancers, small businesses, legal entities).
</p>
<h2 id="signup">2. Account creation</h2>
<p>
You create an account on <a href="https://app.rubis.pro">app.rubis.pro</a>.
A 30-day free trial is granted; after that, the Free plan continues with
5 active invoices unless you upgrade to a paid plan (Pro €19/mo HT or
Business €49/mo HT).
</p>
<h2 id="billing">3. Billing and cancellation</h2>
<p>
Paid plans are billed monthly via Stripe. You can cancel at any time from
your account settings; cancellation takes effect at the end of the current
billing period and no refund is granted for partial periods.
</p>
<h2 id="data">4. Data and content</h2>
<p>
Your invoices and customer data remain your property. Rubis stores them
encrypted in France. You can export or delete your data at any time.
See our <a href="/en/confidentialite">Privacy Policy</a> for details.
</p>
<h2 id="liability">5. Liability</h2>
<p>
Rubis is an automation tool. You remain solely responsible for the content
of reminders sent to your clients and for compliance with applicable law
(notably French LME on payment terms). The publisher's liability is
limited to the amounts paid for the service over the past 12 months.
</p>
<h2 id="law">6. Governing law</h2>
<p>
These Terms are governed by French law. Any dispute will be submitted to
the competent French courts after a good-faith attempt at amicable
resolution.
</p>
<h2 id="contact">7. Contact</h2>
<p>
For any question: <a href="mailto:contact@rubis.pro">contact@rubis.pro</a>.
</p>
<p>
<a href="/cgv">→ Read the full French Terms of Service</a>
</p>
</LegalLayout>

View File

@ -1,388 +0,0 @@
---
/**
* /changelog — Liste reverse-chrono des versions livrées.
*
* Stratégie de rendu : `prerender = true` — le contenu vient de fichiers .md
* versionnés dans le repo (cf. `src/content.config.ts`), donc tout est figé au
* build. À chaque release, on bumpe `apps/web/src/version.ts` + on ajoute un
* nouveau .md dans `src/content/changelog/`, puis le redéploiement régénère
* le HTML.
*
* Design :
* - Hero centré, eyebrow rubis + h1 display
* - 2 colonnes desktop : feed cartes (gauche) + sticky rail (droite)
* - 1 colonne mobile/tablette : pas de rail, juste le feed
* - Anchors `#1.4.0` par version → partageables, scrollables
* - JSON-LD : un `WebPage` avec `mainEntity` listant chaque release comme
* `TechArticle`. Rich snippets dans Google.
*/
export const prerender = true;
import Layout from "../../../layouts/Layout.astro";
import { getCollection, render } from "astro:content";
import { resolveLocale, getTranslations } from "../../../i18n";
const locale = resolveLocale(Astro.currentLocale);
const t = getTranslations(locale);
const entries = (await getCollection("changelog")).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
const rendered = await Promise.all(
entries.map(async (entry) => {
const { Content } = await render(entry);
return { data: entry.data, Content };
}),
);
const intlLocale = locale === "en" ? "en-US" : "fr-FR";
const dateLong = new Intl.DateTimeFormat(intlLocale, {
day: "numeric",
month: "long",
year: "numeric",
});
const dateShort = new Intl.DateTimeFormat(intlLocale, {
day: "2-digit",
month: "short",
});
const typeLabel: Record<string, string> = locale === "en"
? { feature: "New", improvement: "Improvement", fix: "Fix" }
: { feature: "Nouveauté", improvement: "Amélioration", fix: "Correction" };
const title = t.meta.changelog.title;
const description = t.meta.changelog.description;
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebPage",
name: title,
description,
url: "https://rubis.pro/en/changelog",
publisher: {
"@type": "Organization",
name: "Rubis sur l'ongle",
url: "https://rubis.pro",
},
mainEntity: entries.map((e) => ({
"@type": "TechArticle",
headline: e.data.title,
datePublished: e.data.date.toISOString(),
url: `https://rubis.pro/en/changelog#${e.data.version}`,
version: e.data.version,
})),
};
---
<Layout title={title} description={description} solidHeader jsonLd={jsonLd}>
{/* ============ Hero ============ */}
<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>
{locale === "en" ? "What's new" : "Tout ce qui change"}
</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">
{t.changelog.title}
</h1>
<p class="mt-5 max-w-[620px] mx-auto text-[18px] text-ink-2 leading-relaxed">
{t.changelog.subtitle}
</p>
<div class="mt-7 inline-flex items-center gap-4 text-[13px] text-ink-3">
<a
href="/changelog/rss.xml"
class="inline-flex items-center gap-1.5 hover:text-rubis transition-colors"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden
>
<path d="M4 11a9 9 0 0 1 9 9"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
{locale === "en" ? "Subscribe to RSS feed" : "S'abonner au flux RSS"}
</a>
</div>
</div>
</section>
{/* ============ Body : feed + rail ============ */}
<section class="max-w-[1180px] mx-auto px-5 sm:px-8 py-16 lg:py-20">
<div class="lg:grid lg:grid-cols-[1fr_220px] lg:gap-16">
{/* Feed des versions */}
<main class="min-w-0">
{
rendered.length === 0 ? (
<div class="text-center py-16 text-ink-3">
<p>{locale === "en" ? "No entries published yet." : "Aucune entrée publiée pour l'instant."}</p>
</div>
) : (
<ol class="space-y-12 lg:space-y-16 list-none p-0">
{rendered.map(({ data, Content }, index) => {
// Seule la carte la plus récente porte le pill type — pour les
// autres on retire le bruit visuel (le contexte de "Nouveauté"
// s'épuise quand la version a 2 mois). Le pill survivant est
// accentué par le glow autour de la carte (cf. <style> en bas).
const isLatest = index === 0;
return (
<li
id={data.version}
class:list={[
"changelog-card scroll-mt-24 bg-white border rounded-card p-7 sm:p-9 lg:p-10",
isLatest
? "changelog-card--latest border-rubis/25"
: "border-line shadow-soft",
]}
>
{/* Header : chip version + (type si latest) + date */}
<header class="flex items-center flex-wrap gap-3">
<a
href={`#${data.version}`}
class="inline-flex items-center gap-1.5 font-display font-extrabold text-[13px] text-rubis-deep bg-rubis-glow border border-rubis/15 px-3 py-1.5 rounded-full tracking-[-0.005em] hover:bg-rubis hover:text-cream transition-colors no-underline"
aria-label={`${locale === "en" ? "Direct link to version" : "Lien direct vers la version"} ${data.version}`}
>
v{data.version}
</a>
{isLatest && (
<span
class:list={[
"inline-flex items-center text-[11px] uppercase tracking-[0.08em] font-semibold px-2.5 py-1 rounded-full",
data.type === "feature" && "bg-rubis text-cream",
data.type === "improvement" &&
"bg-cream-2 text-ink-2 border border-line",
data.type === "fix" &&
"bg-white text-ink-3 border border-line",
]}
>
{typeLabel[data.type]}
</span>
)}
<time
datetime={data.date.toISOString()}
class="text-[13px] text-ink-3 tabular-nums ml-auto"
>
{dateLong.format(data.date)}
</time>
</header>
{/* Titre */}
<h2 class="mt-5 font-display font-bold text-ink text-[26px] sm:text-[30px] lg:text-[32px] tracking-[-0.022em] leading-[1.15]">
{data.title}
</h2>
{/* Highlights — bullets losanges rubis */}
<ul class="mt-5 space-y-2.5 list-none p-0">
{data.highlights.map((h) => (
<li class="flex items-start gap-3 text-[16px] leading-relaxed text-ink-2">
<span
aria-hidden
class="mt-[10px] inline-block size-[7px] bg-rubis rotate-45 shrink-0"
></span>
<span set:html={h.replace(/`([^`]+)`/g, '<code class="font-mono text-[14.5px] bg-cream-2 px-1.5 py-0.5 rounded border border-line text-ink">$1</code>')} />
</li>
))}
</ul>
{/* Body markdown — narrative courte */}
<div class="mt-6 prose-changelog text-[16px] leading-relaxed text-ink-2">
<Content />
</div>
</li>
);
})}
</ol>
)
}
</main>
{/* Sticky rail — jump nav versions, desktop only */}
<aside class="hidden lg:block" aria-label={locale === "en" ? "Version navigation" : "Navigation des versions"}>
<div class="sticky top-24">
<h2 class="text-[11px] uppercase tracking-[0.08em] font-semibold text-ink-3 mb-4">
{locale === "en" ? "Versions" : "Versions"}
</h2>
<nav class="rail flex flex-col gap-0.5 border-l border-line">
{
entries.map((e) => (
<a
href={`#${e.data.version}`}
class="rail-link group relative flex items-baseline gap-3 pl-5 pr-3 py-2 text-[13px] text-ink-2 hover:text-rubis transition-colors no-underline"
data-version={e.data.version}
>
<span
aria-hidden
class="rail-dot absolute left-[-5px] top-1/2 -translate-y-1/2 size-[9px] rounded-full bg-cream border-2 border-line group-hover:border-rubis transition-colors"
/>
<span class="rail-version font-display font-semibold tabular-nums">
v{e.data.version}
</span>
<span class="rail-date ml-auto text-[11.5px] text-ink-3 tabular-nums">
{dateShort.format(e.data.date)}
</span>
</a>
))
}
</nav>
</div>
</aside>
</div>
</section>
{/* Active state du rail via IntersectionObserver — vanilla JS, inline */}
<script is:inline>
(() => {
const links = document.querySelectorAll(".rail-link");
const cards = document.querySelectorAll(".changelog-card");
if (!links.length || !cards.length) return;
// Map version → link element
const linkByVersion = new Map();
links.forEach((l) => linkByVersion.set(l.dataset.version, l));
function setActive(version) {
links.forEach((l) => {
const active = l.dataset.version === version;
l.classList.toggle("rail-link--active", active);
const dot = l.querySelector(".rail-dot");
if (dot) {
dot.classList.toggle("bg-rubis", active);
dot.classList.toggle("border-rubis", active);
dot.classList.toggle("bg-cream", !active);
}
});
}
// Observe les cartes — l'active est la carte la plus haute encore au-dessus
// de la mi-hauteur viewport.
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
const top = visible[0].target;
const version = top.getAttribute("id");
if (version) setActive(version);
}
},
{
rootMargin: "-20% 0px -60% 0px",
threshold: 0,
},
);
cards.forEach((c) => observer.observe(c));
// Initial active state — première carte
if (cards[0]?.id) setActive(cards[0].id);
})();
</script>
<style is:global>
/* Style des liens rail actifs — appliqué par le script ci-dessus.
En global parce que les classes sont toggled dynamiquement. */
.rail-link--active {
color: var(--color-rubis) !important;
font-weight: 600;
}
.rail-link--active .rail-version {
color: var(--color-rubis);
}
/* Glow autour de la carte la plus récente — superpose 3 couches d'ombres
pour donner un effet "halo rubis chaud" sans agression visuelle :
1. Ring serré crème-rubis (4 px) qui dessine un contour soft
2. Drop shadow proche teintée rubis pour la profondeur
3. Bloom large diffus pour le glow ambiant
Le tout animé en respiration légère (5 s ease-in-out infini), désactivé
quand l'utilisateur préfère pas d'animation (prefers-reduced-motion). */
.changelog-card--latest {
position: relative;
box-shadow:
0 0 0 4px var(--color-rubis-glow),
0 20px 50px -12px rgba(159, 18, 57, 0.22),
0 0 90px -20px rgba(159, 18, 57, 0.35);
animation: changelog-glow-breath 5s ease-in-out infinite;
}
@keyframes changelog-glow-breath {
0%, 100% {
box-shadow:
0 0 0 4px var(--color-rubis-glow),
0 20px 50px -12px rgba(159, 18, 57, 0.22),
0 0 90px -20px rgba(159, 18, 57, 0.35);
}
50% {
box-shadow:
0 0 0 5px var(--color-rubis-glow),
0 24px 60px -10px rgba(159, 18, 57, 0.28),
0 0 120px -16px rgba(159, 18, 57, 0.45);
}
}
@media (prefers-reduced-motion: reduce) {
.changelog-card--latest {
animation: none;
}
}
/* Prose pour le body markdown des entries — typo cohérente avec la landing,
espacements rythmiques, code inline propre. Pas de @tailwindcss/typography
pour pas alourdir le bundle, custom suffit largement vu la simplicité. */
.prose-changelog p {
margin-bottom: 0.9em;
}
.prose-changelog p:last-child {
margin-bottom: 0;
}
.prose-changelog ul {
list-style: none;
padding-left: 0;
margin: 0.5em 0 1em;
}
.prose-changelog ul li {
position: relative;
padding-left: 1.4em;
margin-bottom: 0.4em;
}
.prose-changelog ul li::before {
content: "";
position: absolute;
left: 0;
top: 0.6em;
width: 6px;
height: 6px;
background: var(--color-rubis);
transform: rotate(45deg);
}
.prose-changelog strong {
color: var(--color-ink);
font-weight: 600;
}
.prose-changelog code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.92em;
background: var(--color-cream-2);
border: 1px solid var(--color-line);
padding: 1px 6px;
border-radius: 4px;
color: var(--color-ink);
}
.prose-changelog a {
color: var(--color-rubis);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-changelog a:hover {
color: var(--color-rubis-deep);
}
</style>
</Layout>

View File

@ -1,86 +0,0 @@
---
export const prerender = true;
import LegalLayout from "../../layouts/LegalLayout.astro";
---
<LegalLayout
title="Privacy policy"
description="Privacy policy and personal-data handling at Rubis sur l'ongle."
eyebrow="Your data"
h1={`Privacy <em>policy</em>`}
lede="Rubis sur l'ongle is GDPR-compliant. Your invoices, your clients and your business data stay yours. Here is exactly what we collect, why, and what control you keep."
lastUpdated="May 7, 2026"
>
<div class="callout">
<p>
<strong>English summary.</strong> The official version of this policy is
the French one at <a href="/confidentialite">/confidentialite</a>.
</p>
</div>
<h2 id="controller">1. Data controller</h2>
<p>
<strong>Arthur Barré</strong>, sole trader (SIRET 952 196 442 00018),
8 rue Euthymènes, 13001 Marseille, France. Contact:
<a href="mailto:contact@rubis.pro">contact@rubis.pro</a>.
</p>
<h2 id="data">2. What we collect</h2>
<ul>
<li><strong>Account</strong> — email, hashed password, optional name and company.</li>
<li><strong>Invoices and clients</strong> — invoice PDFs, amounts, due dates, client contact details you upload or enter.</li>
<li><strong>Email tracking</strong> — open/click data on reminders sent through the service.</li>
<li><strong>Usage</strong> — minimal anonymous product analytics (PostHog, EU-hosted).</li>
</ul>
<h2 id="purpose">3. Why we collect it</h2>
<p>
To operate the service: send reminders on your behalf, run OCR on uploaded
documents, generate stats, bill paid plans (via Stripe), and provide
support.
</p>
<h2 id="hosting">4. Where it lives</h2>
<p>
All data is stored encrypted in France on OVH infrastructure. We do not
transfer personal data outside the EU.
</p>
<h2 id="retention">5. Retention</h2>
<p>
Data is kept while your account is active and for up to 12 months after
account closure for legal-archive purposes (invoicing, accounting). You
can request earlier deletion at any time.
</p>
<h2 id="rights">6. Your rights</h2>
<p>
Under GDPR, you can access, rectify, erase, restrict processing of, port,
or object to processing of your personal data. Email
<a href="mailto:contact@rubis.pro">contact@rubis.pro</a> to exercise any
of these rights. You can also file a complaint with the French data
protection authority (CNIL).
</p>
<h2 id="subprocessors">7. Sub-processors</h2>
<ul>
<li><strong>OVH</strong> — hosting (France).</li>
<li><strong>Stripe</strong> — billing (Ireland, GDPR-compliant).</li>
<li><strong>Resend</strong> — outbound email delivery.</li>
<li><strong>PostHog</strong> — anonymous product analytics (EU region).</li>
<li><strong>Powens</strong> — bank read-only connection (when you opt in to bank detection, AISP-licensed by the ACPR).</li>
</ul>
<h2 id="cookies">8. Cookies</h2>
<p>
The landing site sets no analytics or advertising cookies. The app sets
only strictly necessary cookies for authentication and session management.
</p>
<h2 id="updates">9. Updates</h2>
<p>
This policy may evolve. The current version is dated at the top of this
page. Material changes will be notified via email or in-app.
</p>
</LegalLayout>

View File

@ -1,99 +0,0 @@
---
/**
* /en/ — English version of the landing.
* Statique au build (`prerender = true`).
*/
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 { AutoBanking } from "../../components/sections/AutoBanking";
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";
import { getTranslations } from "../../i18n";
const locale = "en" as const;
const t = getTranslations(locale);
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Rubis sur l'ongle",
description: t.meta.home.description,
url: "https://rubis.pro/en/",
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: "en",
publisher: { "@type": "Organization", name: "Rubis sur l'ongle", url: "https://rubis.pro" },
};
---
<Layout title={t.meta.home.title} description={t.meta.home.description} jsonLd={jsonLd}>
<Hero locale={locale} />
<Stats locale={locale} />
<PromiseSection locale={locale} />
<HowItWorks locale={locale} />
<Gamification locale={locale} />
<AutoBanking locale={locale} />
<Legal locale={locale} />
<Pricing locale={locale} />
<FAQ locale={locale} />
<FinalCTA locale={locale} />
<Footnotes locale={locale} />
</Layout>
<script is:inline>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('a[href^="https://app.rubis.pro"]').forEach(function (link) {
link.addEventListener('click', function () {
var section = link.closest('section');
var sectionId = section ? (section.id || 'unknown') : 'header';
if (section && section.id === 'pricing' && link.textContent.trim().match(/^(Commencer|Start)/)) {
window.posthog?.capture('pricing_pro_cta_clicked', {
label: link.textContent.trim(),
});
} else if (section && section.id === 'pricing') {
var planName = link.querySelector('[class*="font-display"][class*="font-bold"]')?.textContent?.trim() || 'unknown';
window.posthog?.capture('pricing_plan_selected', {
plan: planName,
});
} else {
window.posthog?.capture('signup_cta_clicked', {
location: sectionId || 'header',
label: link.textContent.trim(),
});
}
});
});
document.querySelectorAll('header a[href^="https://app.rubis.pro"]').forEach(function (link) {
link.addEventListener('click', function () {
window.posthog?.capture('signup_cta_clicked', {
location: 'header',
label: link.textContent.trim(),
});
});
});
document.querySelectorAll('#faq details').forEach(function (details) {
details.addEventListener('toggle', function () {
if (details.open) {
var question = details.querySelector('summary')?.textContent?.trim() || '';
window.posthog?.capture('faq_item_opened', { question: question });
}
});
});
});
</script>

View File

@ -1,91 +0,0 @@
---
export const prerender = true;
import LegalLayout from "../../layouts/LegalLayout.astro";
---
<LegalLayout
title="Legal notice"
description="Legal notice for rubis.pro and its publisher Rubis sur l'ongle."
eyebrow="Legal information"
h1={`Legal <em>notice</em>`}
lede="Pursuant to French law (LCEN), here are the details about the publisher, the host and the terms of use of the site rubis.pro and the app.rubis.pro application."
lastUpdated="May 7, 2026"
>
<div class="callout">
<p>
<strong>English summary.</strong> The official version of this legal
notice is the French one available at <a href="/mentions-legales">/mentions-legales</a>.
</p>
</div>
<h2 id="publisher">1. Publisher</h2>
<p>
The site <strong>rubis.pro</strong> and the application <strong>app.rubis.pro</strong> are published by:
</p>
<ul>
<li><strong>Arthur Barré</strong>, sole trader.</li>
<li>Address: 8 rue Euthymènes, 13001 Marseille, France.</li>
<li>SIRET: 952 196 442 00018.</li>
<li>VAT: FR60952196442.</li>
</ul>
<h2 id="host">2. Host</h2>
<p>
<strong>OVH SAS</strong> — 2 rue Kellermann, 59100 Roubaix, France. All
infrastructure and user data is located in France.
</p>
<h2 id="director">3. Director of publication</h2>
<p>
<strong>Arthur Barré</strong>, in his capacity as publisher, is responsible
for the publication of the site and the Rubis sur l'ongle application.
</p>
<h2 id="contact">4. Contact</h2>
<p>
General email: <a href="mailto:contact@rubis.pro">contact@rubis.pro</a>.
For data-related requests, see our <a href="/en/confidentialite">Privacy Policy</a>.
</p>
<h2 id="ip">5. Intellectual property</h2>
<p>
All content on the site and the app — including text, the "Rubis sur l'ongle"
trademark, the logo (◆), illustrations, brand palette, template and
application source code — is the exclusive property of Arthur Barré or used
under licence. Any reproduction, representation, modification, publication
or full or partial adaptation is prohibited without prior written
authorisation.
</p>
<h2 id="liability">6. Limitation of liability</h2>
<p>
The publisher strives to keep information accurate and up-to-date but
cannot guarantee the absence of errors or omissions. Regarding the Rubis
app, the publisher provides an automated chasing tool — <strong>the user
remains solely responsible</strong> for the content of reminders sent to
their clients and for compliance with applicable law (notably French LME
on payment terms).
</p>
<h2 id="links">7. External links</h2>
<p>
The site may contain links to third-party sites. The publisher has no
control over those sites and disclaims any responsibility for their
content or privacy practices.
</p>
<h2 id="cookies">8. Cookies</h2>
<p>
The rubis.pro landing site sets no analytics or advertising cookies. The
app.rubis.pro application uses only <strong>strictly necessary</strong>
cookies (authentication session, refresh tokens). Details in the
<a href="/en/confidentialite">Privacy Policy</a>.
</p>
<h2 id="law">9. Governing law and jurisdiction</h2>
<p>
This legal notice is governed by French law. Any dispute will be brought
before the competent French courts.
</p>
</LegalLayout>

View File

@ -1,6 +1,6 @@
---
/**
* / — landing publique de rubis.pro (locale FR par défaut).
* / — landing publique de rubis.pro.
* Statique au build (`prerender = true`).
*/
export const prerender = true;
@ -17,16 +17,13 @@ import { Pricing } from "../components/sections/Pricing";
import { FAQ } from "../components/sections/FAQ";
import { FinalCTA } from "../components/sections/FinalCTA";
import { Footnotes } from "../components/sections/Footnotes";
import { getTranslations } from "../i18n";
const locale = "fr" as const;
const t = getTranslations(locale);
import { copy } from "../copy";
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Rubis sur l'ongle",
description: t.meta.home.description,
description: copy.meta.home.description,
url: "https://rubis.pro",
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
@ -40,18 +37,18 @@ const jsonLd = {
};
---
<Layout title={t.meta.home.title} description={t.meta.home.description} jsonLd={jsonLd}>
<Hero locale={locale} />
<Stats locale={locale} />
<PromiseSection locale={locale} />
<HowItWorks locale={locale} />
<Gamification locale={locale} />
<AutoBanking locale={locale} />
<Legal locale={locale} />
<Pricing locale={locale} />
<FAQ locale={locale} />
<FinalCTA locale={locale} />
<Footnotes locale={locale} />
<Layout title={copy.meta.home.title} description={copy.meta.home.description} jsonLd={jsonLd}>
<Hero />
<Stats />
<PromiseSection />
<HowItWorks />
<Gamification />
<AutoBanking />
<Legal />
<Pricing />
<FAQ />
<FinalCTA />
<Footnotes />
</Layout>
<script is:inline>
@ -60,7 +57,7 @@ const jsonLd = {
link.addEventListener('click', function () {
var section = link.closest('section');
var sectionId = section ? (section.id || 'unknown') : 'header';
if (section && section.id === 'pricing' && link.textContent.trim().match(/^(Commencer|Start)/)) {
if (section && section.id === 'pricing' && link.textContent.trim().match(/^(Commencer|Démarrer)/)) {
window.posthog?.capture('pricing_pro_cta_clicked', {
label: link.textContent.trim(),
});

View File

@ -4,31 +4,26 @@ import { listPosts } from "../lib/api";
const SITE = "https://rubis.pro";
const STATIC_PAGES: Array<{ path: string; priority: string; changefreq: string; alt?: string }> = [
{ path: "/", priority: "1.0", changefreq: "weekly", alt: "/en/" },
{ path: "/blog", priority: "0.9", changefreq: "weekly", alt: "/en/blog" },
{ path: "/changelog", priority: "0.6", changefreq: "weekly", alt: "/en/changelog" },
{ path: "/mentions-legales", priority: "0.3", changefreq: "yearly", alt: "/en/mentions-legales" },
{ path: "/confidentialite", priority: "0.3", changefreq: "yearly", alt: "/en/confidentialite" },
{ path: "/cgv", priority: "0.3", changefreq: "yearly", alt: "/en/cgv" },
const STATIC_PAGES: Array<{ path: string; priority: string; changefreq: string }> = [
{ path: "/", priority: "1.0", changefreq: "weekly" },
{ path: "/blog", priority: "0.9", changefreq: "weekly" },
{ path: "/changelog", priority: "0.6", 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 (FR + EN avec hreflang) + tous
* les articles publiés. Régénéré à chaque requête (cheap : O(N)).
* Sitemap unifié rubis.pro pages statiques + tous les articles publiés.
* Régénéré à chaque requête (cheap : O(N)).
*/
export const GET: APIRoute = async () => {
const posts = await listPosts();
const staticUrls = STATIC_PAGES.flatMap(({ path, priority, changefreq, alt }) => {
const xhtml = alt
? `\n <xhtml:link rel="alternate" hreflang="fr-FR" href="${SITE}${path}" />\n <xhtml:link rel="alternate" hreflang="en" href="${SITE}${alt}" />`
: "";
const frEntry = ` <url>\n <loc>${SITE}${path}</loc>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>${xhtml}\n </url>`;
if (!alt) return [frEntry];
const enEntry = ` <url>\n <loc>${SITE}${alt}</loc>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>${xhtml}\n </url>`;
return [frEntry, enEntry];
}).join("\n");
const staticUrls = STATIC_PAGES.map(
({ path, priority, changefreq }) =>
` <url>\n <loc>${SITE}${path}</loc>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>\n </url>`,
).join("\n");
const postUrls = posts
.map(
@ -41,7 +36,7 @@ export const GET: APIRoute = async () => {
.join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${staticUrls}
${postUrls}
</urlset>`;

View File

@ -0,0 +1,178 @@
# Optimisations landing rubis.pro
> Version : 0.1 · Dernière maj : 2026-05-17
> Source : audit de `https://rubis.pro` mai 2026 + retours commerciaux
> Statut : à arbitrer puis implémenter (cf. section "Plan d'exécution")
Document de travail sur les frictions identifiées sur la landing publique et les optimisations à arbitrer. La landing est déjà dans le top 10% des SaaS B2B français observés (tonalité singulière, métaphore "rubis = 10 minutes" mémorable, trois chiffres d'autorité bien sourcés, section LME avec amendes Fnac/Cdiscount/Sanofi très rassurante, pricing radicalement simple). Ce qui suit ne remet pas en cause l'existant — ce sont les **6 frictions résiduelles qui plombent la conversion d'un visiteur qualifié**.
À traiter avant les actions d'acquisition (réseau direct, experts-comptables, SEO) — inutile d'envoyer du trafic sur une page qui convertit mal.
---
## 1. Ambiguïté du nom et du branding
**Constat.** "Rubis sur l'ongle" est une expression française qui évoque **payer cash, immédiatement, sans délai** — donc du point de vue du **payeur**, pas du créancier. Or notre client est celui qui se fait payer (le créancier qui veut récupérer son dû). Quand un dirigeant de TPE découvre le nom pour la première fois, son cerveau ne fait pas spontanément le lien avec "outil de relance de factures impayées".
**Impact.** Friction cognitive à l'entrée. Le visiteur doit faire un effort de traduction avant de comprendre. Pas dramatique (le H1 et la tagline lèvent l'ambiguïté), mais coûteux sur des canaux où on n'a qu'une fraction de seconde (LinkedIn, search ads, bouche-à-oreille).
**Recommandation.** Ne pas renommer (le nom est joli, sonore, mémorisable, déjà déployé). Mais **renforcer le sous-titre immédiat** sous le wordmark dans le header pour lever toute ambiguïté :
- Actuel (à vérifier sur la page) : tagline "Vos factures relancées toutes seules pendant que vous travaillez."
- Garder cette tagline, mais ajouter une micro-baseline juste sous le logo dans le header : `Logiciel de relance de factures impayées` ou `Recouvrement amiable automatisé`. Quelque chose de fonctionnel et SEO-friendly.
**Action concrète.** Ajouter un `<span>` discret sous le wordmark dans le header de `apps/landing` (taille ~12px, couleur neutre chaude `#7A6E62` ou équivalent). Tester sur 2 semaines, mesurer le taux de rebond.
---
## 2. Le plan Free 5 factures cannibalise le segment cœur
**Constat.** Le plan Free actuel autorise 5 factures actives. Or **le segment cœur de Rubis** (freelance solo, artisan, consultant indépendant, kiné en libéral) émet typiquement **moins de 5 factures par mois**. Conséquence : on offre gratuitement le produit aux gens qui devraient être nos premiers payeurs, et on ne capture que les boîtes plus grosses (qui ont déjà Pennylane, Sellsy, Qonto avec recouvrement intégré, etc.).
**Impact.** Conversion Free → Pro probablement très faible, voire nulle, sur ce segment. C'est le piège classique du Free trop généreux : ça remplit le compteur d'inscrits mais ça ne ramène pas de MRR.
**Recommandation.** Trois options à arbitrer (mon avis : option B).
- **Option A — Supprimer le plan Free.** Remplacer par essai 14 jours uniquement (cf. point 3). Plus radical mais plus rentable.
- **Option B — Limiter le Free à 2 factures actives max** (recommandé). Permet de tester le produit sans le rendre utilisable en production solo. Force la conversion dès que l'usage est réel.
- **Option C — Free dégradé en lecture seule après 14 jours.** Création de factures et envoi de relances limités au plan Pro après l'essai, mais consultation de l'historique reste possible. Plus subtil mais plus complexe à implémenter.
**Action concrète.** Décider entre A/B/C en équipe, créer un ADR (probablement ADR-022 pricing exact, toujours ouvert dans `docs/decisions.md`). Mettre à jour `landing/index.html` + composants pricing dans `apps/landing` et `apps/web`. Tester pendant 30 jours, comparer aux 30 jours précédents.
---
## 3. L'essai 30 jours sans CB est trop long et trop "gratuit"
**Constat.** "30 jours d'essai gratuit, sans carte bancaire" est devenu un standard SaaS — mais c'est **trop long** pour un produit dont la valeur se voit en 7-14 jours (la première relance automatique part en quelques heures après le premier import facture). Et l'absence de CB demandée à l'inscription **divise la conversion par 2 à 3** sur ce type d'outil (donnée connue, cf. études Profitwell, ChartMogul).
**Impact.** Décision repoussée → friction supplémentaire à J+30 → la moitié des essais ne se réactivent jamais.
**Recommandation.**
- Passer à **14 jours d'essai** (largement suffisant pour ressentir la valeur sur 1-3 factures relancées automatiquement).
- **Demander la CB à l'inscription** avec une promesse claire : "Aucun prélèvement avant le J+14. Vous serez notifié 48h avant la facturation et pouvez annuler en un clic depuis votre compte." Cette transparence neutralise la résistance.
- Bonus : envoi automatique d'un email J+12 récapitulant l'usage des 12 premiers jours ("Vous avez relancé X factures, récupéré Y €, gagné Z minutes") — déclencheur émotionnel avant le prélèvement.
**Action concrète.** Mettre à jour le tunnel d'inscription dans `apps/web` (formulaire signup + intégration Stripe Setup Intent au lieu de Payment Intent différé). Mettre à jour le copy sur `landing` ("14 jours d'essai gratuit" et préciser le "CB demandée, non prélevée"). Cohabiter avec un fallback "Pas de CB ? Commencez en plan Free 2 factures" pour ne pas perdre les ultra-réticents.
---
## 4. Zéro preuve sociale visible
**Constat.** Sur un sujet de **confiance** (gestion de factures, connexion bancaire via Powens AISP, données financières sensibles), l'absence totale de :
- logos clients (même un seul)
- témoignages signés (nom + prénom + boîte + photo)
- chiffres d'usage en temps réel ("X factures relancées via Rubis ce mois-ci", "Y € récupérés à ce jour")
- presse, citations, awards
...envoie un signal "SaaS désert" qui coûte massivement en conversion. Même les meilleures landing pages sans preuve sociale tournent à 1-2% de conversion alors qu'avec preuve sociale on monte à 4-8% sur le même trafic qualifié.
**Recommandation court terme** (sans clients payants encore).
1. **Recruter 5-10 bêta-testeurs** dans le réseau direct (cf. plan d'acquisition 30 jours). Leur offrir un **accès Pro gratuit à vie** en échange d'un témoignage signé + droit d'usage de leur logo et photo.
2. **Afficher dès le 1er bêta-testeur recruté** une bande "Ils utilisent Rubis" avec son logo (même si c'est le seul pour le moment — 1 logo signé > 0 logo).
3. **Compteur en temps réel** dès qu'il y a assez de volume (>50 relances envoyées) : "X relances envoyées via Rubis ce mois-ci" — affiché en bas de hero ou en section dédiée. Le compteur peut être branché sur une endpoint de `apps/api` qui agrège.
4. **3 témoignages signés visibles en section dédiée** avant le pricing : prénom + nom complet + société + ville + photo (vraie photo, pas stock). Une phrase concrète sur le bénéfice mesuré, pas du "j'adore Rubis".
**Recommandation moyen terme.** Une fois 20+ clients payants, ajouter des cas clients vidéo (témoignage 60-90s) — déclencheur émotionnel le plus puissant en B2B SMB.
**Action concrète.** Créer une section `<TrustedBy />` dans `apps/landing` (composant réutilisable). Préparer un template d'email "Demande de témoignage en échange d'accès à vie" à envoyer aux 8-10 premiers bêta-testeurs (cf. plan acquisition S1).
---
## 5. Le CTA principal "Lancer Rubis" est trop léger
**Constat.** "Lancer Rubis" est mignon, cohérent avec le branding (verbe d'action, ton singulier), mais **ne dit pas ce qui se passe quand on clique**. Un visiteur qui découvre la page n'a aucune idée de l'engagement (signup ? démo ? téléchargement ?), du temps que ça va prendre, ni du résultat à la sortie. Friction cognitive juste avant le clic — pire moment pour avoir une friction.
**Impact.** Taux de clic CTA probablement en dessous de la moyenne du marché (5-12% sur SaaS B2B SMB français).
**Recommandation.** Réécrire le CTA en deux niveaux : action + sous-texte rassurant.
- CTA primaire : **`Démarrer mon essai 14 jours →`**
- Sous-texte sous le bouton (taille ~13px, neutre chaud) : **`Première relance envoyée en 5 minutes · CB demandée, non prélevée avant J+14`**
Plus long visuellement, mais **enlève toutes les questions** que le prospect se pose à ce moment. Et le "5 minutes" est un déclencheur d'urgence/simplicité très fort.
**Variante à A/B tester** plus tard : `Récupérer mes factures impayées →` (orienté résultat client) vs `Démarrer mon essai 14 jours →` (orienté process). Mon pari : la première variante gagne en taux de clic mais perd en qualification — à mesurer.
**Action concrète.** Mettre à jour le CTA dans hero + footer de `apps/landing`. Conserver le verbe "Lancer Rubis" éventuellement en CTA secondaire (sticky header au scroll) pour garder la touche identitaire.
---
## 6. Le blog n'est pas en navigation prioritaire
**Constat.** Le blog existe (lien présent dans la page) mais n'est pas mis en avant. Or **c'est le seul levier SEO long-terme** de Rubis pour capturer le trafic des gens qui cherchent **avant de connaître l'existence d'un outil** ("comment relancer un client qui ne paie pas", "modèle email relance facture", "délai légal LME", etc.).
**Impact.** Trafic SEO sous-exploité, donc dépendance totale aux canaux outbound (réseau, partenariats, ads) pour générer des leads. Pas scalable.
**Recommandation.**
1. **Promouvoir le blog en nav primaire** (header) à côté de "Tarifs" et "Connexion". Label : `Guides` ou `Ressources` (plus large que "Blog" qui sonne marketing).
2. **Produire 5 articles SEO ciblés** dans les 30 prochains jours (cf. plan acquisition S4) :
- "Modèle d'email de relance facture impayée (gratuit, à copier-coller)"
- "Que faire quand un client ne paie pas une facture ? Guide complet 2026"
- "Délai de paiement légal en France : ce que dit la loi LME"
- "Combien de temps avant de mettre en demeure ? Le bon timing en 2026"
- "Lettre de mise en demeure : modèle Word à télécharger gratuitement"
3. **Chaque article doit ouvrir vers Rubis en CTA discret en fin** ("Et si vous automatisiez tout ça ? Démarrer mon essai 14 jours") — pas en début pour ne pas casser la valeur perçue de l'article.
4. **Lead magnet** : proposer les modèles Word/PDF en téléchargement contre email → liste de remarketing.
**Action concrète.** Mettre à jour la nav dans `apps/landing` (composant header partagé). Créer un template d'article SEO optimisé (frontmatter Astro avec `description`, `keywords`, `publishDate`, schéma JSON-LD `Article`, image OG dédiée). Lancer la rédaction du 1er article cette semaine.
---
## Plan d'exécution priorisé
Découpage en **quick wins** (impact élevé, effort faible — à faire immédiatement) et **chantiers structurants** (impact élevé, effort moyen-élevé — à planifier).
### Quick wins (semaine 1, < 1 jour de dev cumulé)
| # | Action | Fichier(s) impacté(s) | Effort |
|---|---|---|---|
| 1 | Ajouter micro-baseline `Logiciel de relance de factures impayées` sous le wordmark | `apps/landing` header | 15 min |
| 2 | Réécrire CTA principal en `Démarrer mon essai 14 jours →` + sous-texte | `apps/landing` hero + footer | 30 min |
| 3 | Passer essai 30j → 14j dans le copy de la landing | `apps/landing` (multiple sections) | 30 min |
| 4 | Promouvoir le blog en nav primaire (label `Ressources`) | `apps/landing` header component | 30 min |
### Chantiers structurants (semaines 2-4)
| # | Action | Fichier(s) impacté(s) | Effort |
|---|---|---|---|
| 5 | Arbitrer Free 5 factures → Free 2 factures (ADR-022) + implémenter | `apps/api` (quotas), `apps/web` (UI plan), `apps/landing` (pricing) | 1-2 j |
| 6 | Demander CB à l'inscription via Stripe Setup Intent + email J+12 récap | `apps/web` (signup flow), `apps/api` (Stripe integration + emails) | 2-3 j |
| 7 | Composant `<TrustedBy />` + recrutement 5-10 bêta-testeurs en parallèle | `apps/landing` + actions commerciales hors code | 1 j de dev + temps commercial |
| 8 | Rédiger les 5 articles SEO + templates Astro optimisés | `apps/landing` (blog content) | 1-2 j par article |
### Métriques à suivre (avant / après)
À mesurer pendant 30 jours après déploiement des quick wins, puis 30 jours après les chantiers structurants. Outil : Plausible ou Umami (RGPD-friendly, à confirmer ADR si pas encore tranché).
- **Taux de clic CTA hero** (% des visiteurs qui cliquent sur le bouton principal)
- **Taux de conversion signup** (% des visiteurs qui complètent l'inscription)
- **Taux de conversion essai → payant** (% des comptes qui passent en payant à J+14)
- **Taux de rebond landing** (sur trafic SEO/réseau qualifié)
- **Position SEO** sur les 5 mots-clés cibles des articles
---
## Ce qu'on **ne** change **pas**
À noter pour résister à la tentation de tout refondre :
- La **métaphore "rubis = 10 minutes"** reste centrale. C'est l'actif différenciant le plus fort de la marque.
- Les **3 chiffres d'autorité** (44j DSO moyen, 15Md€ de retards, -26% si automatisé) restent en hero — bien sourcés, crédibles.
- La **section loi LME** avec les amendes Fnac/Cdiscount/Sanofi reste — c'est un coup de maître pour rassurer sur le sérieux du problème.
- Le **pricing radicalement simple** (1 plan principal, un sous-choix) reste — c'est un signal de confiance fort vs la concurrence (Upflow, Dunforce avec leurs plans confus à l'enterprise).
- La **tonalité singulière** (ni corporate, ni indie cringe) reste — c'est ce qui rend la landing mémorable.
---
## Décisions ouvertes à trancher
À documenter en ADR dès arbitrage. À discuter en équipe avant exécution des chantiers structurants.
1. **Option Free** : A (supprimer), B (2 factures, recommandé), ou C (lecture seule après 14j) ? → impacte ADR-022.
2. **Conserver "Lancer Rubis" en CTA secondaire** au scroll, ou tout uniformiser sur `Démarrer mon essai 14 jours` ?
3. **Compteur temps réel** sur la landing : à brancher sur `apps/api` (endpoint public agrégé) ou via job batch quotidien ? Trade-off perf vs fraîcheur.
4. **Outil analytics** pour mesurer les métriques ci-dessus : Plausible self-hosted sur LXC Proxmox (cohérent infra), Umami (open-source, self-hostable aussi), ou GA4 (simple mais pas RGPD-friendly) ? → futur ADR.