feat(landing): support i18n EN avec routing /en/* (Astro i18n natif)
Active Astro i18n avec `defaultLocale: fr` et `prefixDefaultLocale: false`
— les URLs FR restent canoniques à la racine, l'EN vit sous `/en/*` pour
ne pas casser le SEO existant.
Architecture :
- `src/i18n/{types,fr,en,index}.ts` — dico FR fait foi (Dict inféré),
EN doit matcher la shape ; helpers `getTranslations(locale)` et
`getAlternateUrl()` pour le language switcher.
- `Layout.astro` lit `Astro.currentLocale`, propage `locale` aux
composants React, set `<html lang>`, og:locale + alt, hreflang.
- `SiteHeader` expose un lien switcher FR↔EN qui préserve la page.
- Toutes les sections (Hero, Stats, Promise, HowItWorks, Gamification,
AutoBanking, Legal, Pricing, FAQ, FinalCTA, Footnotes, SiteFooter)
acceptent une prop `locale` et tirent leurs chaînes du dico.
Pages EN créées :
- `/en/` — home complète
- `/en/blog`, `/en/changelog` — chrome traduit, contenu reste dans la
langue de rédaction (les .md changelog + posts API sont FR)
- `/en/cgv`, `/en/mentions-legales`, `/en/confidentialite` — résumés
courts ; la version juridiquement contraignante reste la FR (droit
français, conformité GDPR/LCEN/LME).
Sitemap mis à jour avec entrées FR/EN + `xhtml:link rel="alternate"`.
Pas de détection auto via Accept-Language pour l'instant — le switcher
header suffit en V1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
cb1195ab73
commit
4f3417fcef
@ -23,6 +23,15 @@ 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(),
|
||||
],
|
||||
|
||||
@ -1,39 +1,44 @@
|
||||
import { Brand } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../i18n";
|
||||
|
||||
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() {
|
||||
export function SiteFooter({ locale = "fr" }: SiteFooterProps) {
|
||||
const t = getTranslations(locale);
|
||||
const prefix = locale === "fr" ? "" : "/en";
|
||||
|
||||
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>
|
||||
<p className="text-[13px] text-ink-3 max-w-md">{t.footer.tagline}</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
|
||||
<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}
|
||||
</a>
|
||||
<a href="/changelog" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
Changelog
|
||||
<a href={`${prefix}/changelog`} className="text-ink-2 hover:text-rubis transition-colors">
|
||||
{t.nav.changelog}
|
||||
</a>
|
||||
<a href="/mentions-legales" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
Mentions légales
|
||||
<a href={`${prefix}/mentions-legales`} className="text-ink-2 hover:text-rubis transition-colors">
|
||||
{t.nav.legal}
|
||||
</a>
|
||||
<a href="/confidentialite" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
Confidentialité
|
||||
<a href={`${prefix}/confidentialite`} className="text-ink-2 hover:text-rubis transition-colors">
|
||||
{t.nav.privacy}
|
||||
</a>
|
||||
<a href="/cgv" className="text-ink-2 hover:text-rubis transition-colors">
|
||||
CGV
|
||||
<a href={`${prefix}/cgv`} className="text-ink-2 hover:text-rubis transition-colors">
|
||||
{t.nav.cgv}
|
||||
</a>
|
||||
<a
|
||||
href="mailto:contact@rubis.pro"
|
||||
@ -45,7 +50,7 @@ export function SiteFooter() {
|
||||
</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.
|
||||
© {CURRENT_YEAR} Rubis sur l'ongle. {t.footer.rights}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Brand, Button, cn } from "@rubis/ui";
|
||||
import { getTranslations, getAlternateUrl, type Locale } from "../i18n";
|
||||
|
||||
const APP_URL = "https://app.rubis.pro";
|
||||
|
||||
@ -6,18 +7,29 @@ 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/* :
|
||||
* - 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).
|
||||
* 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 }: SiteHeaderProps) {
|
||||
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";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@ -29,25 +41,33 @@ export function SiteHeader({ solid = false, className }: SiteHeaderProps) {
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<a href={homeHref} 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">
|
||||
<nav aria-label={t.nav.langLabel} className="flex items-center gap-1.5 sm:gap-3">
|
||||
<a
|
||||
href="/#pricing"
|
||||
href={pricingHref}
|
||||
className="hidden sm:inline-flex h-10 items-center px-3 text-[14px] font-medium text-ink-2 hover:text-rubis transition-colors"
|
||||
>
|
||||
Tarifs
|
||||
{t.nav.pricing}
|
||||
</a>
|
||||
<a
|
||||
href="/blog"
|
||||
href={blogHref}
|
||||
className="hidden sm:inline-flex h-10 items-center px-3 text-[14px] font-medium text-ink-2 hover:text-rubis transition-colors"
|
||||
>
|
||||
Blog
|
||||
{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}
|
||||
</a>
|
||||
<Button asChild size="sm">
|
||||
<a href={APP_URL}>Essai gratuit 30 j</a>
|
||||
<a href={APP_URL}>{t.nav.cta}</a>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -1,76 +1,68 @@
|
||||
import { CheckCircle2, ShieldCheck, Sparkles, Building2 } from "lucide-react";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
type AutoBankingProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function AutoBanking({ locale = "fr" }: AutoBankingProps) {
|
||||
const t = getTranslations(locale).autoBanking;
|
||||
|
||||
/**
|
||||
* Section "Mode automatique — bientôt" : annonce la connexion bancaire
|
||||
* en lecture seule via Powens (AISP). Désactivée par défaut sur l'app
|
||||
* en attendant l'agrément KYC Powens prod. La landing communique
|
||||
* pendant la fenêtre.
|
||||
*
|
||||
* Style : carte cream avec accent rubis, illustration mock à droite,
|
||||
* 4 bénéfices clés, note conformité DSP2 / lecture seule.
|
||||
*/
|
||||
export function AutoBanking() {
|
||||
return (
|
||||
<section id="auto-banking" className="bg-cream">
|
||||
<div className="max-w-[1180px] mx-auto px-5 sm:px-8 py-20 lg:py-28">
|
||||
<div className="grid lg:grid-cols-[1fr_1fr] gap-10 lg:gap-16 items-center">
|
||||
{/* Colonne texte */}
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.16em] font-semibold text-rubis bg-rubis-glow border border-rubis/15 px-3 py-1.5 rounded-full">
|
||||
<Sparkles size={12} aria-hidden="true" />
|
||||
Bientôt disponible
|
||||
{t.badge}
|
||||
</span>
|
||||
|
||||
<h2 className="mt-5 font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px] lg:text-[48px]">
|
||||
Plus jamais besoin de répondre{" "}
|
||||
<em className="text-rubis">« C'est payé »</em>.
|
||||
{t.title_a}
|
||||
<em className="text-rubis">{t.title_em}</em>
|
||||
{t.title_b}
|
||||
</h2>
|
||||
|
||||
<p className="mt-5 text-[17px] text-ink-2 leading-relaxed max-w-[520px]">
|
||||
Connectez votre compte bancaire en lecture seule. Rubis détecte
|
||||
automatiquement les virements de vos clients, marque les
|
||||
factures payées et envoie le mot de remerciement. Vous ne
|
||||
répondez plus à rien.
|
||||
</p>
|
||||
<p className="mt-5 text-[17px] text-ink-2 leading-relaxed max-w-[520px]">{t.body}</p>
|
||||
|
||||
<ul className="mt-7 space-y-3.5">
|
||||
<Benefit>
|
||||
<b className="text-ink">Détection en temps réel</b> via Powens
|
||||
(agréé AISP par l'ACPR).
|
||||
<b className="text-ink">{t.benefit1_bold}</b>
|
||||
{t.benefit1_rest}
|
||||
</Benefit>
|
||||
<Benefit>
|
||||
<b className="text-ink">Toutes les banques françaises</b> —
|
||||
pro ou perso, neo ou traditionnelles.
|
||||
<b className="text-ink">{t.benefit2_bold}</b>
|
||||
{t.benefit2_rest}
|
||||
</Benefit>
|
||||
<Benefit>
|
||||
<b className="text-ink">Mode validation ou auto-pilote</b>{" "}
|
||||
— vous choisissez si Rubis attend votre OK ou marque
|
||||
payée tout seul.
|
||||
<b className="text-ink">{t.benefit3_bold}</b>
|
||||
{t.benefit3_rest}
|
||||
</Benefit>
|
||||
<Benefit>
|
||||
<b className="text-ink">Lecture seule.</b> Aucun déplacement
|
||||
de fonds possible, jamais. Révocable en un clic.
|
||||
<b className="text-ink">{t.benefit4_bold}</b>
|
||||
{t.benefit4_rest}
|
||||
</Benefit>
|
||||
</ul>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-[14px] text-ink-2 bg-white border border-line rounded-default px-4 py-2.5">
|
||||
<Building2 size={16} className="text-rubis" aria-hidden="true" />
|
||||
Inclus sur les plans{" "}
|
||||
<b className="text-ink">Pro</b> et{" "}
|
||||
<b className="text-ink">Business</b>
|
||||
{t.included_a}
|
||||
<b className="text-ink">{t.included_plan1}</b>
|
||||
{t.included_b}
|
||||
<b className="text-ink">{t.included_plan2}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 inline-flex items-center gap-2 text-[12.5px] text-ink-3">
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
Conformité DSP2 · agrément AISP en cours de finalisation
|
||||
{t.compliance}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Colonne illustration : email "Paiement détecté" */}
|
||||
<div className="lg:justify-self-end w-full max-w-[460px]">
|
||||
<DetectedPaymentMock />
|
||||
<DetectedPaymentMock locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -92,18 +84,13 @@ function Benefit({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock d'email "Paiement détecté" — illustre le résultat concret de la
|
||||
* feature. Reprend les codes du ThankYouWidget de HowItWorks pour la
|
||||
* cohérence visuelle (carte blanche, en-tête expéditeur avec gem ◆,
|
||||
* badge rubis-glow).
|
||||
*/
|
||||
function DetectedPaymentMock() {
|
||||
function DetectedPaymentMock({ locale }: { locale: Locale }) {
|
||||
const t = getTranslations(locale).autoBanking;
|
||||
const amount = locale === "fr" ? "4 189,40 €" : "€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" />
|
||||
<div className="relative bg-white border border-line rounded-card overflow-hidden shadow-card">
|
||||
{/* En-tête expéditeur */}
|
||||
<div className="flex items-start gap-3 p-5 border-b border-line bg-cream-2/40">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@ -113,60 +100,47 @@ function DetectedPaymentMock() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="font-semibold text-ink text-[14px] truncate">
|
||||
Rubis sur l'ongle
|
||||
</div>
|
||||
<div className="text-[11px] text-ink-3 tabular-nums shrink-0">
|
||||
il y a 1 min
|
||||
</div>
|
||||
<div className="font-semibold text-ink text-[14px] truncate">{t.mockSender}</div>
|
||||
<div className="text-[11px] text-ink-3 tabular-nums shrink-0">{t.mockWhen}</div>
|
||||
</div>
|
||||
<div className="text-[12.5px] text-ink-3 mt-0.5 truncate">
|
||||
<span className="text-ink-3/80">À : </span>
|
||||
vous@votre-tpe.fr
|
||||
<span className="text-ink-3/80">{t.mockTo}</span>
|
||||
{t.mockToAddr}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corps */}
|
||||
<div className="p-5">
|
||||
<div className="text-center mb-4">
|
||||
<div className="inline-flex size-12 items-center justify-center rounded-full bg-rubis-glow text-rubis-deep mb-3">
|
||||
<CheckCircle2 size={26} strokeWidth={2.5} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="font-display font-bold text-ink text-[18px] leading-tight">
|
||||
Garage Lemoine a payé{" "}
|
||||
<span className="text-rubis">F2026-0013</span>
|
||||
{t.mockTitle_a}
|
||||
<span className="text-rubis">{t.mockTitle_b}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[12.5px] text-ink-3 leading-relaxed">
|
||||
Détecté automatiquement sur votre compte Pro
|
||||
</p>
|
||||
<p className="mt-2 text-[12.5px] text-ink-3 leading-relaxed">{t.mockSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Récap mini */}
|
||||
<div className="bg-cream-2/60 border border-line rounded-default px-4 py-3 space-y-2 text-[13px]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink-3">Client</span>
|
||||
<span className="text-ink font-medium">Garage Lemoine</span>
|
||||
<span className="text-ink-3">{t.mockRowClient}</span>
|
||||
<span className="text-ink font-medium">{t.mockRowClientName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink-3">Facture</span>
|
||||
<span className="text-ink-3">{t.mockRowInvoice}</span>
|
||||
<span className="text-ink font-mono text-[12px]">F2026-0013</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-1 border-t border-line">
|
||||
<span className="text-ink-3">Montant</span>
|
||||
<span className="text-ink-3">{t.mockRowAmount}</span>
|
||||
<span className="font-display font-extrabold text-rubis text-[20px] tabular-nums">
|
||||
4 189,40 €
|
||||
{amount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions auto */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{[
|
||||
"Facture marquée payée",
|
||||
"Relances annulées",
|
||||
"Remerciement envoyé au client",
|
||||
].map((action) => (
|
||||
{[t.mockAction1, t.mockAction2, t.mockAction3].map((action) => (
|
||||
<div
|
||||
key={action}
|
||||
className="flex items-center gap-2 text-[12.5px] text-ink-2"
|
||||
@ -183,7 +157,7 @@ function DetectedPaymentMock() {
|
||||
|
||||
<div className="mt-5 inline-flex items-center gap-1.5 px-2.5 py-1 bg-rubis-glow text-rubis-deep border border-rubis/15 rounded-full text-[11.5px] font-medium">
|
||||
<Sparkles size={11} aria-hidden="true" />
|
||||
+1 rubis · vous n'avez rien eu à faire
|
||||
{t.mockBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,142 +1,25 @@
|
||||
import { Eyebrow } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
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.
|
||||
<br />
|
||||
<br />
|
||||
<b>Bientôt</b>, vous pourrez aussi connecter votre compte bancaire en lecture seule :
|
||||
Rubis détectera les virements entrants automatiquement et marquera la facture payée
|
||||
sans vous demander. Voir la section <a href="#auto-banking" className="text-rubis underline underline-offset-2 hover:no-underline">Mode automatique</a> ci-dessus.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "La connexion bancaire, c'est sécurisé ? Vous pouvez bouger mon argent ?",
|
||||
a: (
|
||||
<>
|
||||
<b>Non, et c'est techniquement impossible.</b> La connexion bancaire passe par{" "}
|
||||
<b>Powens</b>, prestataire AISP agréé par l'ACPR (Autorité de Contrôle Prudentiel et
|
||||
de Résolution). Le statut AISP, défini par la <abbr title="Directive sur les Services de Paiement 2">DSP2</abbr>{" "}
|
||||
européenne, autorise <b>uniquement la lecture</b> des comptes et transactions —{" "}
|
||||
<i>jamais</i> d'initiation de paiement ou de déplacement de fonds.
|
||||
<br />
|
||||
<br />
|
||||
Concrètement : Rubis lit la liste de vos virements entrants pour matcher avec vos
|
||||
factures. Aucune action sortante possible. Vous révoquez l'accès en un clic depuis
|
||||
vos Paramètres, et Powens reçoit l'ordre immédiat de couper la lecture.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
type FAQProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function FAQ({ locale = "fr" }: FAQProps) {
|
||||
const t = getTranslations(locale).faq;
|
||||
|
||||
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>
|
||||
<Eyebrow>{t.eyebrow}</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…
|
||||
{t.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{FAQS.map(({ q, a }, i) => (
|
||||
{t.items.map(({ q, a_html }, i) => (
|
||||
<details
|
||||
key={i}
|
||||
className="group bg-white border border-line rounded-card overflow-hidden hover:border-ink-3 transition-colors"
|
||||
@ -150,7 +33,10 @@ export function FAQ() {
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-6 pb-6 -mt-1 text-[15.5px] leading-relaxed text-ink-2">{a}</div>
|
||||
<div
|
||||
className="px-6 pb-6 -mt-1 text-[15.5px] leading-relaxed text-ink-2"
|
||||
dangerouslySetInnerHTML={{ __html: a_html }}
|
||||
/>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,26 +1,30 @@
|
||||
import { Button } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
const APP_URL = "https://app.rubis.pro";
|
||||
|
||||
export function FinalCTA() {
|
||||
type FinalCTAProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function FinalCTA({ locale = "fr" }: FinalCTAProps) {
|
||||
const t = getTranslations(locale).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.
|
||||
{t.title}
|
||||
</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.
|
||||
{t.body}
|
||||
</p>
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button asChild size="lg">
|
||||
<a href={APP_URL}>Lancer Rubis →</a>
|
||||
<a href={APP_URL}>{t.cta}</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-5 text-[13px] text-ink-3">
|
||||
Inscription en 30 secondes. Annulation 1-clic à tout moment.
|
||||
</p>
|
||||
<p className="mt-5 text-[13px] text-ink-3">{t.hint}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,18 +1,28 @@
|
||||
export function Footnotes() {
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
type FootnotesProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Footnotes({ locale = "fr" }: FootnotesProps) {
|
||||
const t = getTranslations(locale).footnotes;
|
||||
|
||||
return (
|
||||
<aside className="border-t border-line bg-cream">
|
||||
<div className="max-w-[820px] mx-auto px-5 sm:px-8 py-8 space-y-3">
|
||||
<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.
|
||||
<b className="text-ink-2">{t.ocr_bold}</b>
|
||||
{t.ocr_a}
|
||||
<i>{t.ocr_em}</i>
|
||||
{t.ocr_b}
|
||||
</p>
|
||||
<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">DSO</b> — pour <i>Days Sales Outstanding</i>. Le délai
|
||||
moyen, en jours, entre l'émission d'une facture et son encaissement. Plus il est
|
||||
bas, plus votre trésorerie respire.
|
||||
<b className="text-ink-2">{t.dso_bold}</b>
|
||||
{t.dso_a}
|
||||
<i>{t.dso_em}</i>
|
||||
{t.dso_b}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -1,22 +1,27 @@
|
||||
import { Eyebrow, Gem } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
type GamificationProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Gamification({ locale = "fr" }: GamificationProps) {
|
||||
const t = getTranslations(locale).gamification;
|
||||
|
||||
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é
|
||||
{t.eyebrow}
|
||||
</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.
|
||||
{t.title}
|
||||
</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*. Du temps. Concret.
|
||||
{t.body}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center gap-3">
|
||||
@ -26,17 +31,20 @@ export function Gamification() {
|
||||
124
|
||||
</span>
|
||||
<span className="font-display font-medium text-[28px] sm:text-[36px] tracking-[-0.02em] text-cream/80">
|
||||
rubis
|
||||
{t.counterUnit}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[16px] text-cream/85">
|
||||
≈ <b className="text-cream">24 h 48</b> de votre mois
|
||||
{t.counterApprox_a}
|
||||
<b className="text-cream">{t.counterApprox_b}</b>
|
||||
{t.counterApprox_c}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-10 max-w-[460px] mx-auto text-[14px] text-cream/70">
|
||||
Les meilleurs utilisateurs libèrent jusqu'à{" "}
|
||||
<b className="text-cream">30 heures par mois</b>.
|
||||
{t.footer_a}
|
||||
<b className="text-cream">{t.footer_bold}</b>
|
||||
{t.footer_b}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import { Brand, Button, Eyebrow, Gem, cn } from "@rubis/ui";
|
||||
import { Check } from "lucide-react";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
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() {
|
||||
type HeroProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Hero({ locale = "fr" }: HeroProps) {
|
||||
const t = getTranslations(locale).hero;
|
||||
const pricingHref = locale === "fr" ? "#pricing" : "/en/#pricing";
|
||||
|
||||
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"
|
||||
@ -23,127 +24,121 @@ export function Hero() {
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Eyebrow>{t.eyebrow}</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.
|
||||
{t.title_a}
|
||||
<em>{t.title_b}</em>
|
||||
{t.title_c}
|
||||
</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>.
|
||||
{t.body_a}
|
||||
<b className="text-ink">{t.body_bold}</b>
|
||||
{t.body_c}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Button asChild size="lg">
|
||||
<a href={APP_URL}>Lancer Rubis →</a>
|
||||
<a href={APP_URL}>{t.ctaPrimary}</a>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="lg">
|
||||
<a href="#pricing">Voir les tarifs</a>
|
||||
<a href={pricingHref}>{t.ctaSecondary}</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
|
||||
{t.feature1}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-3">
|
||||
<span aria-hidden className="size-[3px] rounded-full bg-ink-3" />
|
||||
Hébergement souverain
|
||||
{t.feature2}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-3">
|
||||
<span aria-hidden className="size-[3px] rounded-full bg-ink-3" />
|
||||
Made in France 🇫🇷
|
||||
{t.feature3}
|
||||
</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 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* Topbar mock — identifie la carte comme un dashboard Rubis.pro,
|
||||
pas juste un widget de chiffres flottants. */}
|
||||
<div className="flex items-center justify-between px-6 sm:px-7 lg:px-8 py-3.5 border-b border-line bg-cream/40">
|
||||
<Brand withSuffix gemSize={18} />
|
||||
<span className="text-[11px] uppercase tracking-[0.08em] font-semibold text-ink-3">
|
||||
Tableau de bord
|
||||
{t.mockDashboard}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="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
|
||||
124 {t.mockRubis}
|
||||
</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.
|
||||
{t.mockHoursPrefix}
|
||||
<b className="text-ink">{t.mockHoursValue}</b>
|
||||
{t.mockHoursSuffix}
|
||||
</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é
|
||||
{t.mockKpiCollected}
|
||||
</div>
|
||||
<div className="mt-1.5 font-display font-bold text-[22px] tracking-[-0.015em] text-ink tabular-nums">
|
||||
14 320 €
|
||||
{locale === "fr" ? "14 320 €" : "€14,320"}
|
||||
</div>
|
||||
<div className="mt-1 text-[11.5px] text-rubis font-medium">
|
||||
+ 2 800 € vs avril
|
||||
{t.mockKpiCollectedDelta}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.06em] font-semibold text-ink-3">
|
||||
DSO*
|
||||
{t.mockKpiDso}
|
||||
</div>
|
||||
<div className="mt-1.5 font-display font-bold text-[22px] tracking-[-0.015em] text-ink tabular-nums">
|
||||
38 j
|
||||
{locale === "fr" ? "38 j" : "38 d"}
|
||||
</div>
|
||||
<div className="mt-1 text-[11.5px] text-rubis font-medium">
|
||||
↘ −6 j depuis Rubis
|
||||
{t.mockKpiDsoDelta}
|
||||
</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
|
||||
{t.mockToday}
|
||||
</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>
|
||||
{t.mockActivity1Pre}
|
||||
<b className="text-ink">{t.mockActivity1Name}</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
|
||||
{t.mockActivity2Pre}
|
||||
<b className="text-ink">{t.mockActivity2Name}</b>
|
||||
{t.mockActivity2Post}
|
||||
</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>
|
||||
<span className="text-ink-2">{t.mockActivity3}</span>
|
||||
<time className="text-ink-3 tabular-nums text-[11.5px]">09:48</time>
|
||||
</li>
|
||||
</ul>
|
||||
@ -151,7 +146,6 @@ export function Hero() {
|
||||
</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"
|
||||
@ -167,7 +161,7 @@ export function Hero() {
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
~3 minutes le matin
|
||||
{t.mockBadge}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,79 +1,68 @@
|
||||
import { Eyebrow, cn } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
type HowItWorksProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function HowItWorks({ locale = "fr" }: HowItWorksProps) {
|
||||
const t = getTranslations(locale).how;
|
||||
|
||||
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>
|
||||
<Eyebrow>{t.eyebrow}</Eyebrow>
|
||||
<h2 className="mt-4 font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px]">
|
||||
Quatre étapes. C'est tout.
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">
|
||||
Vraiment. Parfois trois, si le client paye dès la première relance.
|
||||
</p>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<Step
|
||||
num="01"
|
||||
title="Vous importez vos factures."
|
||||
body="PDF, photo prise depuis votre téléphone, scan reçu par mail — peu importe. L'OCR* lit, extrait montant, client, échéance. Vous vérifiez. Vingt secondes par facture, montre en main."
|
||||
>
|
||||
<DropzoneWidget />
|
||||
<Step num="01" title={t.step1Title} body={t.step1Body} stepLabel={t.stepLabel}>
|
||||
<DropzoneWidget fileLabel={t.widgetFile} />
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
flip
|
||||
num="02"
|
||||
title="Vous choisissez un plan de relance."
|
||||
title={t.step2Title}
|
||||
stepLabel={t.stepLabel}
|
||||
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>
|
||||
<p>{t.step2BodyP1}</p>
|
||||
<p className="mt-3">{t.step2BodyP2}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CalendarWidget />
|
||||
<CalendarWidget t={t} />
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
num="03"
|
||||
title="Vous validez. La machine fait le reste."
|
||||
body={
|
||||
<p>
|
||||
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.
|
||||
L'algorithme fait le reste.
|
||||
</p>
|
||||
}
|
||||
title={t.step3Title}
|
||||
stepLabel={t.stepLabel}
|
||||
body={<p>{t.step3Body}</p>}
|
||||
>
|
||||
<AssistantWidget />
|
||||
<AssistantWidget t={t} />
|
||||
</Step>
|
||||
|
||||
<Step
|
||||
flip
|
||||
num="04"
|
||||
title="Le paiement tombe. Votre client est remercié."
|
||||
title={t.step4Title}
|
||||
stepLabel={t.stepLabel}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
Quand vous validez « Payée », Rubis envoie automatiquement un mot court à votre
|
||||
client : « Merci, paiement bien reçu ». C'est optionnel, configurable — mais
|
||||
beaucoup l'activent. Parce qu'un client remercié est un client qui revient.
|
||||
</p>
|
||||
<p>{t.step4Body}</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.
|
||||
{t.step4Reward}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ThankYouWidget />
|
||||
<ThankYouWidget t={t} />
|
||||
</Step>
|
||||
</div>
|
||||
</section>
|
||||
@ -86,14 +75,13 @@ type StepProps = {
|
||||
body: React.ReactNode;
|
||||
flip?: boolean;
|
||||
children: React.ReactNode;
|
||||
stepLabel: string;
|
||||
};
|
||||
|
||||
function Step({ num, title, body, flip = false, children }: StepProps) {
|
||||
function Step({ num, title, body, flip = false, children, stepLabel }: 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",
|
||||
@ -101,7 +89,7 @@ function Step({ num, title, body, flip = false, children }: StepProps) {
|
||||
>
|
||||
<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}
|
||||
{stepLabel} {num}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
@ -117,8 +105,10 @@ function Step({ num, title, body, flip = false, children }: StepProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type HowDict = ReturnType<typeof getTranslations>["how"];
|
||||
|
||||
/* ============== Widget 01 — Dropzone ============== */
|
||||
function DropzoneWidget() {
|
||||
function DropzoneWidget({ fileLabel }: { fileLabel: string }) {
|
||||
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>
|
||||
@ -140,19 +130,20 @@ function DropzoneWidget() {
|
||||
1 240,00 €
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[12px] text-ink-3 font-mono">facture-2024-0042.pdf</div>
|
||||
<div className="text-[12px] text-ink-3 font-mono">{fileLabel}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============== Widget 02 — Calendrier ============== */
|
||||
function CalendarWidget() {
|
||||
const days = Array.from({ length: 35 }, (_, i) => i - 3); // 4 jours vides, puis 1-31
|
||||
function CalendarWidget({ t }: { t: HowDict }) {
|
||||
const days = Array.from({ length: 35 }, (_, i) => i - 3);
|
||||
const marked = new Set([8, 15, 25]);
|
||||
const weekdays = t.widgetWeekdays.split(",");
|
||||
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="font-display font-bold text-ink">{t.widgetMonth}</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">
|
||||
‹
|
||||
@ -164,7 +155,7 @@ function CalendarWidget() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5 text-center text-[12px]">
|
||||
{["L", "M", "M", "J", "V", "S", "D"].map((d, i) => (
|
||||
{weekdays.map((d, i) => (
|
||||
<div key={i} className="text-ink-3 font-semibold uppercase tracking-wider pb-2">
|
||||
{d}
|
||||
</div>
|
||||
@ -191,19 +182,18 @@ function CalendarWidget() {
|
||||
<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
|
||||
{t.widgetCalLegend}
|
||||
</span>
|
||||
<span className="text-ink-3">3 étapes · plan B2B</span>
|
||||
<span className="text-ink-3">{t.widgetCalMeta}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============== Widget 04 — Email de remerciement ============== */
|
||||
function ThankYouWidget() {
|
||||
function ThankYouWidget({ t }: { t: HowDict }) {
|
||||
return (
|
||||
<div className="w-full max-w-[420px] bg-white border border-line rounded-card overflow-hidden shadow-soft">
|
||||
{/* En-tête expéditeur — mime un client mail (Apple Mail / Gmail). */}
|
||||
<div className="flex items-start gap-3 p-5 border-b border-line">
|
||||
<div
|
||||
aria-hidden
|
||||
@ -213,30 +203,28 @@ function ThankYouWidget() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="font-semibold text-ink text-[14px] truncate">Studio Lumière</div>
|
||||
<div className="text-[11px] text-ink-3 tabular-nums shrink-0">il y a 2 min</div>
|
||||
<div className="font-semibold text-ink text-[14px] truncate">{t.widgetEmailSender}</div>
|
||||
<div className="text-[11px] text-ink-3 tabular-nums shrink-0">{t.widgetEmailWhen}</div>
|
||||
</div>
|
||||
<div className="text-[12.5px] text-ink-3 mt-0.5 truncate">
|
||||
<span className="text-ink-3/80">À : </span>
|
||||
client@boulangerie-paul.fr
|
||||
<span className="text-ink-3/80">{t.widgetEmailTo}</span>
|
||||
{t.widgetEmailToAddr}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sujet + corps de l'email */}
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="font-display font-semibold text-ink text-[15.5px] leading-tight">
|
||||
Merci, paiement bien reçu
|
||||
{t.widgetEmailSubject}
|
||||
</div>
|
||||
<p className="text-[13.5px] text-ink-2 leading-relaxed">
|
||||
Bonjour, nous confirmons la bonne réception de votre règlement de
|
||||
{" "}
|
||||
<span className="font-semibold text-ink tabular-nums">1 240,00 €</span>.
|
||||
Belle journée — à très vite.
|
||||
{t.widgetEmailBody_a}
|
||||
<span className="font-semibold text-ink tabular-nums">1 240,00 €</span>
|
||||
{t.widgetEmailBody_b}
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-rubis-glow text-rubis-deep border border-rubis/15 rounded-full text-[11.5px] font-medium">
|
||||
<span aria-hidden className="size-[6px] bg-rubis rotate-45" />
|
||||
Envoyé automatiquement
|
||||
{t.widgetEmailAuto}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -244,7 +232,7 @@ function ThankYouWidget() {
|
||||
}
|
||||
|
||||
/* ============== Widget 03 — Assistant (mini illustration) ============== */
|
||||
function AssistantWidget() {
|
||||
function AssistantWidget({ t }: { t: HowDict }) {
|
||||
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">
|
||||
@ -252,7 +240,7 @@ function AssistantWidget() {
|
||||
◆
|
||||
</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 ?
|
||||
{t.widgetAssistantQ}
|
||||
<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"
|
||||
@ -264,16 +252,16 @@ function AssistantWidget() {
|
||||
type="button"
|
||||
className="px-4 py-2 bg-rubis text-white rounded-default text-[13px] font-semibold shadow-rubis"
|
||||
>
|
||||
✓ Oui
|
||||
{t.widgetAssistantYes}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-white border border-line text-ink rounded-default text-[13px] font-medium"
|
||||
>
|
||||
Pas encore
|
||||
{t.widgetAssistantNotYet}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[12px] text-ink-3 italic">2 secondes, et la machine s'occupe du reste.</div>
|
||||
<div className="text-[12px] text-ink-3 italic">{t.widgetAssistantHint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,72 +1,73 @@
|
||||
import { Eyebrow } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
const SANCTIONED = [
|
||||
["Fnac Darty", "3,9 M€"],
|
||||
["Cdiscount", "2,1 M€"],
|
||||
["Sanofi", "1,65 M€"],
|
||||
["LCL", "1,5 M€"],
|
||||
];
|
||||
["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"],
|
||||
] as const;
|
||||
|
||||
type LegalProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Legal({ locale = "fr" }: LegalProps) {
|
||||
const t = getTranslations(locale).legal;
|
||||
|
||||
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>
|
||||
<Eyebrow>{t.eyebrow}</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.
|
||||
{t.title}
|
||||
</h2>
|
||||
|
||||
<div className="mt-6 space-y-5 text-[17px] leading-relaxed text-ink-2">
|
||||
<p>
|
||||
La{" "}
|
||||
{t.p1_a}
|
||||
<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.
|
||||
{t.p1_lme}
|
||||
</span>
|
||||
{t.p1_b}
|
||||
<b className="text-ink">{t.p1_bold1}</b>
|
||||
{t.p1_c}
|
||||
<b className="text-ink">{t.p1_bold2}</b>
|
||||
{t.p1_d}
|
||||
</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.
|
||||
{t.p2_a}
|
||||
<b className="text-ink">{t.p2_bold}</b>
|
||||
{t.p2_b}
|
||||
</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>
|
||||
<span>{t.clipHeader}</span>
|
||||
<span>{t.clipYear}</span>
|
||||
</div>
|
||||
|
||||
<div className="font-display font-extrabold text-rubis text-[56px] sm:text-[68px] leading-none tracking-[-0.035em]">
|
||||
47 M€
|
||||
{t.clipBigNum}
|
||||
</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>
|
||||
<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, amount]) => (
|
||||
{SANCTIONED.map(([name, amountFr, amountEn]) => (
|
||||
<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>
|
||||
<span className="text-rubis font-display font-bold tabular-nums">
|
||||
{locale === "fr" ? amountFr : amountEn}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="py-2.5 text-[13px] italic text-ink-3">
|
||||
… et 405 autres entreprises contrôlées
|
||||
</li>
|
||||
<li className="py-2.5 text-[13px] italic text-ink-3">{t.clipMore}</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>
|
||||
<p className="mt-4 pt-4 border-t border-line text-[11.5px] text-ink-3">{t.clipSource}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,80 +1,64 @@
|
||||
import { Button, Eyebrow } from "@rubis/ui";
|
||||
import { Check } from "lucide-react";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
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."],
|
||||
[
|
||||
"Détection bancaire automatique",
|
||||
"à venir : connectez votre banque (lecture seule, AISP), Rubis marque les factures payées tout seul.",
|
||||
],
|
||||
["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;
|
||||
type PricingProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Pricing({ locale = "fr" }: PricingProps) {
|
||||
const t = getTranslations(locale).pricing;
|
||||
|
||||
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>
|
||||
<Eyebrow>{t.eyebrow}</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.
|
||||
{t.title}
|
||||
</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>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">{t.subtitle}</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
|
||||
{t.proBadge}
|
||||
</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>.
|
||||
{t.proName_a}
|
||||
<em>{t.proName_em}</em>
|
||||
{t.proName_b}
|
||||
</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 €
|
||||
{locale === "fr" ? "19 €" : "€19"}
|
||||
</span>
|
||||
<span className="font-sans text-[14px] text-ink-3 leading-tight">
|
||||
par mois
|
||||
<br />
|
||||
hors taxes
|
||||
<span className="font-sans text-[14px] text-ink-3 leading-tight whitespace-pre-line">
|
||||
{t.proPriceUnit}
|
||||
</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.
|
||||
{t.proBody_a}
|
||||
<b className="text-ink">{t.proBody_bold}</b>
|
||||
{t.proBody_b}
|
||||
</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>
|
||||
<a href={APP_URL}>{t.proCta}</a>
|
||||
</Button>
|
||||
<span className="text-[13px] text-ink-3">
|
||||
Sans engagement, annulable à tout moment
|
||||
</span>
|
||||
<span className="text-[13px] text-ink-3">{t.proCtaHint}</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
|
||||
{t.included}
|
||||
</div>
|
||||
<ul className="space-y-3.5">
|
||||
{FEATURES.map(([head, tail]) => (
|
||||
{t.features.map(([head, tail]) => (
|
||||
<li key={head} className="flex gap-3 text-[15px] leading-snug text-ink-2">
|
||||
<Check
|
||||
size={18}
|
||||
@ -91,39 +75,45 @@ export function Pricing() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asides */}
|
||||
<div className="text-center my-12 text-[14px] uppercase tracking-[0.2em] font-semibold text-ink-3">
|
||||
— ou —
|
||||
{t.or}
|
||||
</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"
|
||||
name={t.freeName}
|
||||
price={t.freePrice}
|
||||
qualifier={t.freeQualifier}
|
||||
startCta={t.startCta}
|
||||
startCtaSuffix={t.startCtaSuffix}
|
||||
perMonth={t.perMonth}
|
||||
>
|
||||
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.
|
||||
{t.freeBody_a}
|
||||
<b className="text-ink">{t.freeBody_bold}</b>
|
||||
{t.freeBody_b}
|
||||
</PricingAside>
|
||||
<PricingAside
|
||||
name="Plan Business"
|
||||
price="49 €"
|
||||
qualifier="Pour les équipes & les pros exigeants"
|
||||
name={t.businessName}
|
||||
price={t.businessPrice}
|
||||
qualifier={t.businessQualifier}
|
||||
startCta={t.startCta}
|
||||
startCtaSuffix={t.startCtaSuffix}
|
||||
perMonth={t.perMonth}
|
||||
>
|
||||
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> (
|
||||
{t.businessBody_a}
|
||||
<b className="text-ink">{t.businessBody_bold1}</b>
|
||||
{t.businessBody_b}
|
||||
<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]">
|
||||
compta@votre-entreprise.fr
|
||||
{locale === "fr" ? "compta@votre-entreprise.fr" : "accounts@your-company.com"}
|
||||
</code>
|
||||
), pas d'une adresse Rubis — vos clients ne se rendent compte de rien et vos emails
|
||||
arrivent mieux en boîte principale.
|
||||
{t.businessBody_d}
|
||||
</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.
|
||||
{t.closing}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -135,12 +125,22 @@ function PricingAside({
|
||||
price,
|
||||
qualifier,
|
||||
children,
|
||||
startCta,
|
||||
startCtaSuffix,
|
||||
perMonth,
|
||||
}: {
|
||||
name: string;
|
||||
price: string;
|
||||
qualifier: string;
|
||||
children: React.ReactNode;
|
||||
startCta: string;
|
||||
startCtaSuffix: string;
|
||||
perMonth: string;
|
||||
}) {
|
||||
// Strip "Plan" or "plan" prefix/suffix word from name to use in CTA.
|
||||
const shortName = name
|
||||
.replace(/^Plan\s+/i, "")
|
||||
.replace(/\s+plan$/i, "");
|
||||
return (
|
||||
<a
|
||||
href={APP_URL}
|
||||
@ -152,7 +152,7 @@ function PricingAside({
|
||||
</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 className="font-sans font-normal text-[13px] text-ink-3 ml-1">{perMonth}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-[12.5px] uppercase tracking-[0.1em] font-semibold text-ink-3">
|
||||
@ -160,7 +160,7 @@ function PricingAside({
|
||||
</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
|
||||
{startCta} {shortName} {startCtaSuffix}
|
||||
<span className="transition-transform group-hover:translate-x-0.5">→</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@ -1,43 +1,47 @@
|
||||
import { Eyebrow } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
type PromiseProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Promise({ locale = "fr" }: PromiseProps) {
|
||||
const t = getTranslations(locale).promise;
|
||||
|
||||
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>
|
||||
<Eyebrow>{t.eyebrow}</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 est <em>plus précieux</em>.
|
||||
{t.title_a}
|
||||
<em>{t.title_em}</em>
|
||||
{t.title_b}
|
||||
</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">{t.p1}</p>
|
||||
<p className="text-[17.5px] leading-relaxed text-ink-2">
|
||||
Vous n'avez pas créé votre entreprise pour passer vos journées à 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>
|
||||
<p className="text-[15px] leading-relaxed text-ink-3 italic">
|
||||
Parfois moins, si votre plan par défaut est bien réglé.
|
||||
{t.p2_a}
|
||||
<b className="text-ink">{t.p2_bold1}</b>
|
||||
{t.p2_b}
|
||||
<b className="text-ink">{t.p2_bold2}</b>
|
||||
{t.p2_c}
|
||||
</p>
|
||||
<p className="text-[15px] leading-relaxed text-ink-3 italic">{t.p3}</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
|
||||
{t.box.title}
|
||||
</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 €"],
|
||||
[t.box.row1Label, t.box.row1Value],
|
||||
[t.box.row2Label, t.box.row2Value],
|
||||
[t.box.row3Label, t.box.row3Value],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className="flex items-center justify-between py-3 text-[14px]">
|
||||
<dt className="text-ink-2">{label}</dt>
|
||||
@ -45,11 +49,9 @@ export function Promise() {
|
||||
</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>
|
||||
<dt className="text-ink font-semibold text-[14px]">{t.box.totalLabel}</dt>
|
||||
<dd className="font-display font-extrabold text-rubis text-[20px] tabular-nums">
|
||||
12 600 €
|
||||
{t.box.totalValue}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@ -1,39 +1,31 @@
|
||||
import { Eyebrow } from "@rubis/ui";
|
||||
import { getTranslations, type Locale } from "../../i18n";
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
type StatsProps = {
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Stats({ locale = "fr" }: StatsProps) {
|
||||
const t = getTranslations(locale).stats;
|
||||
const items = [
|
||||
{ num: t.item1Num, desc: t.item1Desc, source: t.item1Source },
|
||||
{ num: t.item2Num, desc: t.item2Desc, source: t.item2Source },
|
||||
{ num: t.item3Num, desc: t.item3Desc, source: t.item3Source },
|
||||
];
|
||||
|
||||
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>
|
||||
<Eyebrow>{t.eyebrow}</Eyebrow>
|
||||
<h2 className="mt-4 font-display font-bold text-ink leading-[1.1] tracking-[-0.025em] text-[34px] sm:text-[44px]">
|
||||
Trois chiffres exorbitants.
|
||||
{t.title}
|
||||
</h2>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">
|
||||
Et vous faites sûrement partie intégrante de ces enquêtes.
|
||||
</p>
|
||||
<p className="mt-4 text-[17px] text-ink-2 leading-relaxed">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-5 lg:gap-7">
|
||||
{STATS.map((s) => (
|
||||
{items.map((s) => (
|
||||
<div
|
||||
key={s.num}
|
||||
className="bg-white border border-line rounded-card p-7 lg:p-8 shadow-soft"
|
||||
|
||||
381
apps/landing/src/i18n/en.ts
Normal file
381
apps/landing/src/i18n/en.ts
Normal file
@ -0,0 +1,381 @@
|
||||
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 10–15% 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
377
apps/landing/src/i18n/fr.ts
Normal file
377
apps/landing/src/i18n/fr.ts
Normal file
@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Dictionnaire FR — source de vérité pour les types du module i18n.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
export const fr = {
|
||||
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.",
|
||||
},
|
||||
cgv: {
|
||||
title: "Conditions générales de vente — Rubis",
|
||||
description: "Conditions générales de vente du service Rubis sur l'ongle.",
|
||||
},
|
||||
privacy: {
|
||||
title: "Politique de confidentialité — Rubis",
|
||||
description: "Politique de confidentialité et traitement des données personnelles chez Rubis sur l'ongle.",
|
||||
},
|
||||
legal: {
|
||||
title: "Mentions légales — Rubis",
|
||||
description: "Mentions légales du site rubis.pro et de l'éditeur Rubis sur l'ongle.",
|
||||
},
|
||||
blog: {
|
||||
title: "Blog Rubis — gestion de trésorerie pour TPE-PME",
|
||||
description: "Conseils, retours d'expérience et benchmarks sur la relance de factures et la gestion de trésorerie des TPE-PME françaises.",
|
||||
},
|
||||
changelog: {
|
||||
title: "Changelog Rubis",
|
||||
description: "L'historique des nouveautés, améliorations et corrections déployées sur Rubis sur l'ongle.",
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
pricing: "Tarifs",
|
||||
blog: "Blog",
|
||||
changelog: "Changelog",
|
||||
legal: "Mentions légales",
|
||||
privacy: "Confidentialité",
|
||||
cgv: "CGV",
|
||||
cta: "Essai gratuit 30 j",
|
||||
langSwitch: "English",
|
||||
langLabel: "Langue",
|
||||
},
|
||||
footer: {
|
||||
tagline: "Le SaaS de relance de factures impayées pour TPE-PME françaises. Fait à Paris, avec du temps libéré.",
|
||||
rights: "Tous droits réservés.",
|
||||
linksAria: "Liens utiles",
|
||||
},
|
||||
hero: {
|
||||
eyebrow: "L'outil de relance pour TPE-PME françaises",
|
||||
title_a: "Vos factures relancées ",
|
||||
title_b: "toutes seules",
|
||||
title_c: " pendant que vous travaillez.",
|
||||
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 →",
|
||||
ctaSecondary: "Voir les tarifs",
|
||||
feature1: "30 jours gratuits puis Free 5 factures",
|
||||
feature2: "Hébergement souverain",
|
||||
feature3: "Made in France 🇫🇷",
|
||||
mockDashboard: "Tableau de bord",
|
||||
mockRubis: "rubis",
|
||||
mockHoursPrefix: "≈ ",
|
||||
mockHoursValue: "24 h 48",
|
||||
mockHoursSuffix: " que vous n'avez pas passées à relancer.",
|
||||
mockKpiCollected: "Encaissé",
|
||||
mockKpiCollectedDelta: "+ 2 800 € vs avril",
|
||||
mockKpiDso: "DSO*",
|
||||
mockKpiDsoDelta: "↘ −6 j depuis Rubis",
|
||||
mockToday: "Aujourd'hui",
|
||||
mockActivity1Pre: "📤 Relance envoyée à ",
|
||||
mockActivity1Name: "Atelier Durand",
|
||||
mockActivity2Pre: "✓ Facture ",
|
||||
mockActivity2Name: "F-2024-0035",
|
||||
mockActivity2Post: " encaissée",
|
||||
mockActivity3: "📥 3 factures importées et OCRisées",
|
||||
mockBadge: "~3 minutes le matin",
|
||||
},
|
||||
stats: {
|
||||
eyebrow: "L'état des paiements en France",
|
||||
title: "Trois chiffres exorbitants.",
|
||||
subtitle: "Et vous faites sûrement partie intégrante de ces enquêtes.",
|
||||
item1Num: "44 j",
|
||||
item1Desc: "Le retard moyen de paiement pour une facture émise par une TPE en France.",
|
||||
item1Source: "Source : Altares · Observatoire des délais de paiement, 2024",
|
||||
item2Num: "15 Md€",
|
||||
item2Desc: "De trésorerie bloquée chez les PME françaises à cause des retards de paiement.",
|
||||
item2Source: "Source : Banque de France · Rapport annuel 2024",
|
||||
item3Num: "−26 %",
|
||||
item3Desc: "De chances d'être payé si vous attendez plus de 30 jours pour relancer.",
|
||||
item3Source: "Source : AFDCC · Étude crédit management",
|
||||
},
|
||||
promise: {
|
||||
eyebrow: "Notre conviction",
|
||||
title_a: "Votre temps est ",
|
||||
title_em: "plus précieux",
|
||||
title_b: ".",
|
||||
p1: "Vous n'avez pas créé votre entreprise pour passer vos journées à 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.",
|
||||
p2_a: "Les PME qui automatisent leurs relances passent de ",
|
||||
p2_bold1: "8 heures par semaine",
|
||||
p2_b: " à ",
|
||||
p2_bold2: "moins de 3",
|
||||
p2_c: ". Soit 5 heures de votre vie récupérées. Toutes les semaines. Pour toujours.",
|
||||
p3: "Parfois moins, si votre plan par défaut est bien réglé.",
|
||||
box: {
|
||||
title: "Votre temps en chiffres",
|
||||
row1Label: "Heures perdues / semaine",
|
||||
row1Value: "5 h",
|
||||
row2Label: "Sur un mois",
|
||||
row2Value: "~ 21 h",
|
||||
row3Label: "À 50 €/h facturés",
|
||||
row3Value: "1 050 €",
|
||||
totalLabel: "Coût annuel d'une relance manuelle",
|
||||
totalValue: "12 600 €",
|
||||
},
|
||||
},
|
||||
how: {
|
||||
eyebrow: "Comment ça marche",
|
||||
title: "Quatre étapes. C'est tout.",
|
||||
subtitle: "Vraiment. Parfois trois, si le client paye dès la première relance.",
|
||||
stepLabel: "Étape",
|
||||
step1Title: "Vous importez vos factures.",
|
||||
step1Body: "PDF, photo prise depuis votre téléphone, scan reçu par mail — peu importe. L'OCR* lit, extrait montant, client, échéance. Vous vérifiez. Vingt secondes par facture, montre en main.",
|
||||
step2Title: "Vous choisissez un plan de relance.",
|
||||
step2BodyP1: "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.",
|
||||
step2BodyP2: "Et bien sûr, vous pouvez aussi créer les vôtres sur mesure.",
|
||||
step3Title: "Vous validez. La machine fait le reste.",
|
||||
step3Body: "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. L'algorithme fait le reste.",
|
||||
step4Title: "Le paiement tombe. Votre client est remercié.",
|
||||
step4Body: "Quand vous validez « Payée », Rubis envoie automatiquement un mot court à votre client : « Merci, paiement bien reçu ». C'est optionnel, configurable — mais beaucoup l'activent. Parce qu'un client remercié est un client qui revient.",
|
||||
step4Reward: "La récompense : votre compteur de rubis grimpe. Tranquillement. Comme une bonne nouvelle régulière.",
|
||||
widgetFile: "facture-2024-0042.pdf",
|
||||
widgetMonth: "Mai 2026",
|
||||
widgetWeekdays: "L,M,M,J,V,S,D",
|
||||
widgetCalLegend: "Relances programmées",
|
||||
widgetCalMeta: "3 étapes · plan B2B",
|
||||
widgetEmailSender: "Studio Lumière",
|
||||
widgetEmailWhen: "il y a 2 min",
|
||||
widgetEmailTo: "À : ",
|
||||
widgetEmailToAddr: "client@boulangerie-paul.fr",
|
||||
widgetEmailSubject: "Merci, paiement bien reçu",
|
||||
widgetEmailBody_a: "Bonjour, nous confirmons la bonne réception de votre règlement de ",
|
||||
widgetEmailBody_b: ". Belle journée — à très vite.",
|
||||
widgetEmailAuto: "Envoyé automatiquement",
|
||||
widgetAssistantQ: "Cette facture a-t-elle été réglée ?",
|
||||
widgetAssistantYes: "✓ Oui",
|
||||
widgetAssistantNotYet: "Pas encore",
|
||||
widgetAssistantHint: "2 secondes, et la machine s'occupe du reste.",
|
||||
},
|
||||
gamification: {
|
||||
eyebrow: "La devise du temps gagné",
|
||||
title: "1 rubis = 10 minutes de votre vie.",
|
||||
body: "À 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*. Du temps. Concret.",
|
||||
counterUnit: "rubis",
|
||||
counterApprox_a: "≈ ",
|
||||
counterApprox_b: "24 h 48",
|
||||
counterApprox_c: " de votre mois",
|
||||
footer_a: "Les meilleurs utilisateurs libèrent jusqu'à ",
|
||||
footer_bold: "30 heures par mois",
|
||||
footer_b: ".",
|
||||
},
|
||||
autoBanking: {
|
||||
badge: "Bientôt disponible",
|
||||
title_a: "Plus jamais besoin de répondre ",
|
||||
title_em: "« C'est payé »",
|
||||
title_b: ".",
|
||||
body: "Connectez votre compte bancaire en lecture seule. Rubis détecte automatiquement les virements de vos clients, marque les factures payées et envoie le mot de remerciement. Vous ne répondez plus à rien.",
|
||||
benefit1_bold: "Détection en temps réel",
|
||||
benefit1_rest: " via Powens (agréé AISP par l'ACPR).",
|
||||
benefit2_bold: "Toutes les banques françaises",
|
||||
benefit2_rest: " — pro ou perso, neo ou traditionnelles.",
|
||||
benefit3_bold: "Mode validation ou auto-pilote",
|
||||
benefit3_rest: " — vous choisissez si Rubis attend votre OK ou marque payée tout seul.",
|
||||
benefit4_bold: "Lecture seule.",
|
||||
benefit4_rest: " Aucun déplacement de fonds possible, jamais. Révocable en un clic.",
|
||||
included_a: "Inclus sur les plans ",
|
||||
included_plan1: "Pro",
|
||||
included_b: " et ",
|
||||
included_plan2: "Business",
|
||||
compliance: "Conformité DSP2 · agrément AISP en cours de finalisation",
|
||||
mockSender: "Rubis sur l'ongle",
|
||||
mockWhen: "il y a 1 min",
|
||||
mockTo: "À : ",
|
||||
mockToAddr: "vous@votre-tpe.fr",
|
||||
mockTitle_a: "Garage Lemoine a payé ",
|
||||
mockTitle_b: "F2026-0013",
|
||||
mockSubtitle: "Détecté automatiquement sur votre compte Pro",
|
||||
mockRowClient: "Client",
|
||||
mockRowClientName: "Garage Lemoine",
|
||||
mockRowInvoice: "Facture",
|
||||
mockRowAmount: "Montant",
|
||||
mockAction1: "Facture marquée payée",
|
||||
mockAction2: "Relances annulées",
|
||||
mockAction3: "Remerciement envoyé au client",
|
||||
mockBadge: "+1 rubis · vous n'avez rien eu à faire",
|
||||
},
|
||||
legal: {
|
||||
eyebrow: "Vous êtes dans votre droit",
|
||||
title: "La loi est de votre côté. On vous évite juste de la brandir.",
|
||||
p1_a: "La ",
|
||||
p1_lme: "loi LME",
|
||||
p1_b: " plafonne les délais de paiement entre entreprises à ",
|
||||
p1_bold1: "60 jours",
|
||||
p1_c: " (ou 45 jours fin de mois). Les sanctions peuvent atteindre ",
|
||||
p1_bold2: "2 millions d'euros",
|
||||
p1_d: ". En 2025, le Sénat a voté à l'unanimité un durcissement supplémentaire des règles.",
|
||||
p2_a: "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 ",
|
||||
p2_bold: "vous",
|
||||
p2_b: " qui l'envoyez, sur validation manuelle.",
|
||||
clipHeader: "Sanctions DGCCRF",
|
||||
clipYear: "2025",
|
||||
clipBigNum: "47 M€",
|
||||
clipCaption: "de pénalités prononcées contre les mauvais payeurs français l'an dernier.",
|
||||
clipMore: "… et 405 autres entreprises contrôlées",
|
||||
clipSource: "Source · DGCCRF, bilan annuel 2025 · economie.gouv.fr",
|
||||
},
|
||||
pricing: {
|
||||
eyebrow: "Tarifs",
|
||||
title: "Moins cher qu'une heure de votre temps mensuel.",
|
||||
subtitle: "On va droit au but. Un plan principal qu'on recommande à 99 % d'entre vous, et deux options autour. C'est tout.",
|
||||
proBadge: "Le plan qu'on recommande",
|
||||
proName_a: "Le plan ",
|
||||
proName_em: "Pro",
|
||||
proName_b: ".",
|
||||
proPriceUnit: "par mois\nhors taxes",
|
||||
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 →",
|
||||
proCtaHint: "Sans engagement, annulable à tout moment",
|
||||
included: "Ce qui est inclus",
|
||||
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."],
|
||||
[
|
||||
"Détection bancaire automatique",
|
||||
"à venir : connectez votre banque (lecture seule, AISP), Rubis marque les factures payées tout seul.",
|
||||
],
|
||||
["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."],
|
||||
],
|
||||
or: "— ou —",
|
||||
perMonth: "/mois",
|
||||
startCta: "Démarrer",
|
||||
startCtaSuffix: "maintenant",
|
||||
freeName: "Plan Free",
|
||||
freePrice: "0 €",
|
||||
freeQualifier: "Pour tester ou démarrer en freelance",
|
||||
freeBody_a: "Le plan Free fait tourner Rubis sur ",
|
||||
freeBody_bold: "5 factures actives",
|
||||
freeBody_b: " en permanence. Gratuit, pour de bon. Notre façon de prouver que la promesse tient.",
|
||||
businessName: "Plan Business",
|
||||
businessPrice: "49 €",
|
||||
businessQualifier: "Pour les équipes & les pros exigeants",
|
||||
businessBody_a: "Tout du Pro, plus : ",
|
||||
businessBody_bold1: "jusqu'à 5 collaborateurs",
|
||||
businessBody_b: " dans la boîte, chacun avec son accès. Vos relances partent de ",
|
||||
businessBody_bold2: "votre vraie adresse pro",
|
||||
businessBody_c: " (",
|
||||
businessBody_d: "), pas d'une adresse Rubis — vos clients ne se rendent compte de rien et vos emails arrivent mieux en boîte principale.",
|
||||
closing: "Pas de palier caché. Pas de surcoût à l'usage. Annulation en un clic, sans question posée.",
|
||||
},
|
||||
faq: {
|
||||
eyebrow: "Questions fréquentes",
|
||||
title: "Vous vous demandez sûrement…",
|
||||
items: [
|
||||
{
|
||||
q: "Et si mon client paie hors plateforme — comment Rubis le sait ?",
|
||||
a_html:
|
||||
"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.<br><br><b>Bientôt</b>, vous pourrez aussi connecter votre compte bancaire en lecture seule : Rubis détectera les virements entrants automatiquement et marquera la facture payée sans vous demander. Voir la section <a href=\"#auto-banking\" class=\"text-rubis underline underline-offset-2 hover:no-underline\">Mode automatique</a> ci-dessus.",
|
||||
},
|
||||
{
|
||||
q: "La connexion bancaire, c'est sécurisé ? Vous pouvez bouger mon argent ?",
|
||||
a_html:
|
||||
"<b>Non, et c'est techniquement impossible.</b> La connexion bancaire passe par <b>Powens</b>, prestataire AISP agréé par l'ACPR (Autorité de Contrôle Prudentiel et de Résolution). Le statut AISP, défini par la <abbr title=\"Directive sur les Services de Paiement 2\">DSP2</abbr> européenne, autorise <b>uniquement la lecture</b> des comptes et transactions — <i>jamais</i> d'initiation de paiement ou de déplacement de fonds.<br><br>Concrètement : Rubis lit la liste de vos virements entrants pour matcher avec vos factures. Aucune action sortante possible. Vous révoquez l'accès en un clic depuis vos Paramètres, et Powens reçoit l'ordre immédiat de couper la lecture.",
|
||||
},
|
||||
{
|
||||
q: "Mes factures et données restent-elles privées ?",
|
||||
a_html:
|
||||
"É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_html:
|
||||
"Oui, dès le plan Pro. Tous les emails sont des templates avec variables (<code class=\"bg-cream-2 px-1.5 py-0.5 rounded text-[13px]\">{{prenom_client}}</code>, <code class=\"bg-cream-2 px-1.5 py-0.5 rounded text-[13px]\">{{numero}}</code>, <code class=\"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_html:
|
||||
"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 class=\"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_html:
|
||||
"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_html:
|
||||
"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_html:
|
||||
"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_html:
|
||||
"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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
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 →",
|
||||
hint: "Inscription en 30 secondes. Annulation 1-clic à tout moment.",
|
||||
},
|
||||
footnotes: {
|
||||
ocr_bold: "OCR",
|
||||
ocr_a: " — pour ",
|
||||
ocr_em: "Optical Character Recognition",
|
||||
ocr_b: ". La reconnaissance automatique du texte sur un PDF ou une photo. La machine lit votre facture par-dessus votre épaule, en somme.",
|
||||
dso_bold: "DSO",
|
||||
dso_a: " — pour ",
|
||||
dso_em: "Days Sales Outstanding",
|
||||
dso_b: ". Le délai moyen, en jours, entre l'émission d'une facture et son encaissement. Plus il est bas, plus votre trésorerie respire.",
|
||||
},
|
||||
blog: {
|
||||
indexTitle: "Le blog",
|
||||
indexSubtitle: "Conseils, retours d'expérience et benchmarks sur la relance de factures et la gestion de trésorerie des TPE-PME.",
|
||||
empty: "Pas encore d'articles publiés. Revenez bientôt !",
|
||||
readMore: "Lire l'article →",
|
||||
publishedOn: "Publié le",
|
||||
backToBlog: "← Retour au blog",
|
||||
minutesRead: "min de lecture",
|
||||
},
|
||||
changelog: {
|
||||
title: "Changelog",
|
||||
subtitle: "L'historique des nouveautés, améliorations et corrections déployées sur Rubis.",
|
||||
rssLabel: "Flux RSS",
|
||||
types: {
|
||||
major: "Mise à jour majeure",
|
||||
minor: "Mise à jour",
|
||||
patch: "Correctif",
|
||||
},
|
||||
highlights: "Au programme",
|
||||
},
|
||||
legalPages: {
|
||||
legal: {
|
||||
title: "Mentions légales",
|
||||
backHome: "← Retour à l'accueil",
|
||||
},
|
||||
privacy: {
|
||||
title: "Politique de confidentialité",
|
||||
backHome: "← Retour à l'accueil",
|
||||
},
|
||||
cgv: {
|
||||
title: "Conditions générales de vente",
|
||||
backHome: "← Retour à l'accueil",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Dict = typeof fr;
|
||||
43
apps/landing/src/i18n/index.ts
Normal file
43
apps/landing/src/i18n/index.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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";
|
||||
15
apps/landing/src/i18n/types.ts
Normal file
15
apps/landing/src/i18n/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@ -14,6 +14,7 @@ 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
|
||||
@ -67,6 +68,13 @@ 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 :
|
||||
* - Si le titre est court (<45 chars) ET ne contient pas "Rubis", on ajoute
|
||||
@ -81,13 +89,14 @@ const fullTitle =
|
||||
title.length < 45 && !title.includes("Rubis") ? `${title} — Rubis` : title;
|
||||
|
||||
const resolvedOgImage = ogImage ?? DEFAULT_OG_IMAGE;
|
||||
const url = `${SITE_URL}${pathname ?? Astro.url.pathname}`;
|
||||
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="fr">
|
||||
<html lang={htmlLang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@ -98,12 +107,14 @@ 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="fr-FR" href={url} />
|
||||
<link rel="alternate" hreflang="x-default" 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="fr_FR" />
|
||||
<meta property="og:locale" content={ogLocale} />
|
||||
<meta property="og:locale:alternate" content={ogLocaleAlt} />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
@ -153,10 +164,10 @@ const jsonLdArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||
<PostHog />
|
||||
</head>
|
||||
<body>
|
||||
<SiteHeader solid={solidHeader} />
|
||||
<SiteHeader solid={solidHeader} locale={locale} currentPath={currentPath} />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter locale={locale} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -9,12 +9,14 @@
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { PostCard } from "../../components/blog/PostCard";
|
||||
import { listPosts } from "../../lib/api";
|
||||
import { resolveLocale, getTranslations } from "../../i18n";
|
||||
|
||||
const locale = resolveLocale(Astro.currentLocale);
|
||||
const t = getTranslations(locale);
|
||||
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 title = t.meta.blog.title;
|
||||
const description = t.meta.blog.description;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
@ -54,17 +56,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>
|
||||
Blog Rubis
|
||||
{locale === "fr" ? "Blog Rubis" : "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"
|
||||
>
|
||||
Le blog des relances qui marchent
|
||||
{t.blog.indexTitle}
|
||||
</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.
|
||||
{t.blog.indexSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -73,7 +73,7 @@ Astro.response.headers.set(
|
||||
{
|
||||
posts.length === 0 ? (
|
||||
<div class="text-center py-16 text-ink-3">
|
||||
<p>Aucun article publié pour l'instant. Reviens vite.</p>
|
||||
<p>{t.blog.empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||
|
||||
@ -20,6 +20,10 @@ 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(),
|
||||
@ -32,25 +36,23 @@ const rendered = await Promise.all(
|
||||
}),
|
||||
);
|
||||
|
||||
const dateLong = new Intl.DateTimeFormat("fr-FR", {
|
||||
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("fr-FR", {
|
||||
const dateShort = new Intl.DateTimeFormat(intlLocale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
feature: "Nouveauté",
|
||||
improvement: "Amélioration",
|
||||
fix: "Correction",
|
||||
};
|
||||
const typeLabel: Record<string, string> = locale === "en"
|
||||
? { feature: "New", improvement: "Improvement", fix: "Fix" }
|
||||
: { feature: "Nouveauté", improvement: "Amélioration", fix: "Correction" };
|
||||
|
||||
const title = "Changelog — ce qui change dans Rubis";
|
||||
const description =
|
||||
"Toutes les nouveautés, améliorations et corrections livrées sur Rubis. Régulier, transparent, sans superlatifs.";
|
||||
const title = t.meta.changelog.title;
|
||||
const description = t.meta.changelog.description;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
@ -79,14 +81,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>
|
||||
Tout ce qui change
|
||||
{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">
|
||||
Changelog
|
||||
{t.changelog.title}
|
||||
</h1>
|
||||
<p class="mt-5 max-w-[620px] mx-auto text-[18px] text-ink-2 leading-relaxed">
|
||||
Les nouveautés, les améliorations, les corrections livrées sur Rubis.
|
||||
Régulier, transparent, sans superlatifs.
|
||||
{t.changelog.subtitle}
|
||||
</p>
|
||||
<div class="mt-7 inline-flex items-center gap-4 text-[13px] text-ink-3">
|
||||
<a
|
||||
@ -108,7 +109,7 @@ const jsonLd = {
|
||||
<path d="M4 4a16 16 0 0 1 16 16"></path>
|
||||
<circle cx="5" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
S'abonner au flux RSS
|
||||
{locale === "en" ? "Subscribe to RSS feed" : "S'abonner au flux RSS"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -122,7 +123,7 @@ const jsonLd = {
|
||||
{
|
||||
rendered.length === 0 ? (
|
||||
<div class="text-center py-16 text-ink-3">
|
||||
<p>Aucune entrée publiée pour l'instant.</p>
|
||||
<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">
|
||||
@ -147,7 +148,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={`Lien direct vers la version ${data.version}`}
|
||||
aria-label={`${locale === "en" ? "Direct link to version" : "Lien direct vers la version"} ${data.version}`}
|
||||
>
|
||||
v{data.version}
|
||||
</a>
|
||||
@ -204,10 +205,10 @@ const jsonLd = {
|
||||
</main>
|
||||
|
||||
{/* Sticky rail — jump nav versions, desktop only */}
|
||||
<aside class="hidden lg:block" aria-label="Navigation des versions">
|
||||
<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">
|
||||
Versions
|
||||
{locale === "en" ? "Versions" : "Versions"}
|
||||
</h2>
|
||||
<nav class="rail flex flex-col gap-0.5 border-l border-line">
|
||||
{
|
||||
|
||||
57
apps/landing/src/pages/en/blog/index.astro
Normal file
57
apps/landing/src/pages/en/blog/index.astro
Normal file
@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* /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>
|
||||
76
apps/landing/src/pages/en/cgv.astro
Normal file
76
apps/landing/src/pages/en/cgv.astro
Normal file
@ -0,0 +1,76 @@
|
||||
---
|
||||
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>
|
||||
388
apps/landing/src/pages/en/changelog/index.astro
Normal file
388
apps/landing/src/pages/en/changelog/index.astro
Normal file
@ -0,0 +1,388 @@
|
||||
---
|
||||
/**
|
||||
* /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>
|
||||
86
apps/landing/src/pages/en/confidentialite.astro
Normal file
86
apps/landing/src/pages/en/confidentialite.astro
Normal file
@ -0,0 +1,86 @@
|
||||
---
|
||||
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>
|
||||
99
apps/landing/src/pages/en/index.astro
Normal file
99
apps/landing/src/pages/en/index.astro
Normal file
@ -0,0 +1,99 @@
|
||||
---
|
||||
/**
|
||||
* /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>
|
||||
91
apps/landing/src/pages/en/mentions-legales.astro
Normal file
91
apps/landing/src/pages/en/mentions-legales.astro
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
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>
|
||||
@ -1,11 +1,7 @@
|
||||
---
|
||||
/**
|
||||
* / — 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.
|
||||
* / — landing publique de rubis.pro (locale FR par défaut).
|
||||
* Statique au build (`prerender = true`).
|
||||
*/
|
||||
export const prerender = true;
|
||||
|
||||
@ -21,16 +17,16 @@ 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 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 locale = "fr" as const;
|
||||
const t = getTranslations(locale);
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Rubis sur l'ongle",
|
||||
description,
|
||||
description: t.meta.home.description,
|
||||
url: "https://rubis.pro",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
@ -44,34 +40,31 @@ const jsonLd = {
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} jsonLd={jsonLd}>
|
||||
<Hero />
|
||||
<Stats />
|
||||
<PromiseSection />
|
||||
<HowItWorks />
|
||||
<Gamification />
|
||||
<AutoBanking />
|
||||
<Legal />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<FinalCTA />
|
||||
<Footnotes />
|
||||
<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 () {
|
||||
// Track CTA clicks linking to the app (Hero, Header, FinalCTA)
|
||||
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';
|
||||
// Distinguish the Pro plan CTA inside #pricing from generic CTAs
|
||||
if (section && section.id === 'pricing' && link.textContent.trim().startsWith('Commencer')) {
|
||||
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') {
|
||||
// Free / Business aside cards
|
||||
var planName = link.querySelector('[class*="font-display"][class*="font-bold"]')?.textContent?.trim() || 'unknown';
|
||||
window.posthog?.capture('pricing_plan_selected', {
|
||||
plan: planName,
|
||||
@ -85,7 +78,6 @@ const jsonLd = {
|
||||
});
|
||||
});
|
||||
|
||||
// Track header CTA separately (lives outside <section>)
|
||||
document.querySelectorAll('header a[href^="https://app.rubis.pro"]').forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
window.posthog?.capture('signup_cta_clicked', {
|
||||
@ -95,7 +87,6 @@ const jsonLd = {
|
||||
});
|
||||
});
|
||||
|
||||
// Track FAQ accordion opens
|
||||
document.querySelectorAll('#faq details').forEach(function (details) {
|
||||
details.addEventListener('toggle', function () {
|
||||
if (details.open) {
|
||||
|
||||
@ -4,29 +4,31 @@ 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" },
|
||||
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" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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)).
|
||||
*/
|
||||
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 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 postUrls = posts
|
||||
.map(
|
||||
@ -39,7 +41,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">
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
${staticUrls}
|
||||
${postUrls}
|
||||
</urlset>`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user