diff --git a/apps/web/package.json b/apps/web/package.json
index 94307c3..f072be5 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.2.5",
"@fontsource-variable/inter": "^5.2.5",
+ "@posthog/react": "^1.9.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
@@ -28,7 +29,6 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@rubis/shared": "workspace:*",
"@rubis/ui": "workspace:*",
- "@posthog/react": "^1.9.0",
"@sentry/react": "^10.52.0",
"@tanstack/react-form": "^1.0.0",
"@tanstack/react-query": "^5.66.0",
@@ -36,15 +36,17 @@
"@tanstack/react-router": "^1.114.3",
"@tanstack/react-router-devtools": "^1.114.3",
"@tuyau/client": "^0.2.10",
+ "@uiw/react-md-editor": "^4.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
- "@uiw/react-md-editor": "^4.0.5",
+ "i18next": "^26.2.0",
"lucide-react": "^0.475.0",
+ "posthog-js": "^1.250.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+ "react-i18next": "^17.0.8",
"recharts": "^3.8.1",
- "posthog-js": "^1.250.0",
"sonner": "^1.7.4",
"tailwind-merge": "^3.0.1",
"zod": "^3.24.1"
diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx
index eded520..f61e23a 100644
--- a/apps/web/src/components/layout/AppSidebar.tsx
+++ b/apps/web/src/components/layout/AppSidebar.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Link } from "@tanstack/react-router";
+import { useTranslation } from "react-i18next";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
ChevronsLeft,
@@ -37,6 +38,7 @@ const STORAGE_KEY = "rubis.sidebar.collapsed";
*/
export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
const { user } = useAuth();
+ const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(false);
// Lecture localStorage à l'init (côté client uniquement).
@@ -69,7 +71,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
"mb-10 flex items-center justify-center",
collapsed ? "px-0" : "px-2 justify-start",
)}
- aria-label="Accueil Rubis"
+ aria-label="Rubis"
>
{collapsed ? : }
@@ -78,37 +80,37 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
}
- label="Dashboard"
+ label={t("nav.dashboard")}
collapsed={collapsed}
/>
}
- label="Factures"
+ label={t("nav.factures")}
collapsed={collapsed}
/>
}
- label="Plans de relance"
+ label={t("nav.plans")}
collapsed={collapsed}
/>
}
- label="Clients"
+ label={t("nav.clients")}
collapsed={collapsed}
/>
}
- label="Insights"
+ label={t("nav.insights")}
collapsed={collapsed}
/>
}
- label="Paramètres"
+ label={t("nav.parametres")}
collapsed={collapsed}
/>
@@ -117,7 +119,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
}
- label="Blog (admin)"
+ label={t("nav.adminBlog")}
collapsed={collapsed}
/>
)}
@@ -129,7 +131,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
) : (
- Rubis ce mois
+ {t("dashboard.kpi.toCollect")}
@@ -138,17 +140,16 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
- ≈ {formatRubisToHours(rubisThisMonth)} libérées
+ ≈ {formatRubisToHours(rubisThisMonth)}
)}
- {/* Toggle replier / déplier */}
@@ -181,7 +184,7 @@ function RubisCounterCompact({ value }: { value: number }) {
"cursor-default",
)}
role="status"
- aria-label={`${value} rubis ce mois, soit ${formatRubisToHours(value)} libérées`}
+ aria-label={`${value} ${t("hero.mockRubis", { defaultValue: monthLabel })}`}
>
@@ -198,8 +201,8 @@ function RubisCounterCompact({ value }: { value: number }) {
"data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95",
)}
>
- Rubis ce mois
- · ≈ {formatRubisToHours(value)} libérées
+ {monthLabel}
+ · ≈ {formatRubisToHours(value)}
diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx
index bcd6fc4..c63c946 100644
--- a/apps/web/src/components/layout/MobileTabBar.tsx
+++ b/apps/web/src/components/layout/MobileTabBar.tsx
@@ -1,41 +1,39 @@
import { Home, FileText, ListChecks, Plus } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { NavLink } from "./NavLink";
/**
* Tab bar mobile — fixed bottom, 4 entrées max.
*
- * Pas de "Clients" : on y accède depuis une facture (cf. wireframe 4.3).
- * Pas de "Réglages" non plus : disponible via l'avatar topbar (UserMenu).
- *
* Slot 4 = "+ Nouvelle facture" (= /factures/import) — l'action la plus
- * fréquente sur mobile (photo + drop), accessible en 1 tap depuis n'importe
- * où dans l'app.
+ * fréquente sur mobile (photo + drop).
*/
export function MobileTabBar() {
+ const { t } = useTranslation();
return (
- } label="Accueil" />
+ } label={t("nav.dashboard")} />
}
- label="Factures"
+ label={t("nav.factures")}
/>
}
- label="Plans"
+ label={t("nav.plans")}
/>
}
- label="Nouvelle"
+ label={t("common.add")}
/>
diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx
index ce53007..f1f200c 100644
--- a/apps/web/src/components/layout/UserMenu.tsx
+++ b/apps/web/src/components/layout/UserMenu.tsx
@@ -1,6 +1,7 @@
import * as Popover from "@radix-ui/react-popover";
import { useNavigate } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { ChevronDown, LogOut, Settings as SettingsIcon } from "lucide-react";
import { toast } from "sonner";
@@ -10,18 +11,17 @@ import { cn } from "@/lib/utils";
/**
* UserMenu — avatar initiales + popover avec logout.
- * Radix Popover pour l'a11y (focus management, échap, click-outside).
*/
export function UserMenu() {
const { user } = useAuth();
const navigate = useNavigate();
+ const { t } = useTranslation();
const logoutMutation = useMutation({
mutationFn: async () => api.post("/api/v1/account/logout"),
onSettled: () => {
- // Quoi qu'il arrive (succès ou échec réseau), on clear la session locale.
authStore.clear();
- toast.success("À très vite.");
+ toast.success(t("toasts.saved"));
void navigate({ to: "/login" });
},
});
@@ -33,7 +33,7 @@ export function UserMenu() {
.map((part) => part[0]?.toUpperCase() ?? "")
.join("") ?? "?";
- const firstName = user?.fullName?.split(" ")[0] ?? "Utilisateur";
+ const firstName = user?.fullName?.split(" ")[0] ?? "—";
return (
@@ -43,7 +43,7 @@ export function UserMenu() {
"py-1 pl-1 pr-2.5 transition-colors hover:border-ink-3",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
- aria-label="Ouvrir le menu utilisateur"
+ aria-label={t("account.badge")}
>
- Paramètres
+ {t("nav.parametres")}
- Se déconnecter
+ {t("nav.logout")}
diff --git a/apps/web/src/components/settings/LanguageSwitcher.tsx b/apps/web/src/components/settings/LanguageSwitcher.tsx
new file mode 100644
index 0000000..b27399a
--- /dev/null
+++ b/apps/web/src/components/settings/LanguageSwitcher.tsx
@@ -0,0 +1,63 @@
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+import { Globe } from "lucide-react";
+
+import { Card } from "@rubis/ui";
+import { Eyebrow } from "@rubis/ui";
+import { setLocale, type Locale } from "@/i18n";
+
+const OPTIONS: Array<{ value: Locale; flag: string; labelKey: string }> = [
+ { value: "fr", flag: "🇫🇷", labelKey: "parametres.language.fr" },
+ { value: "en", flag: "🇬🇧", labelKey: "parametres.language.en" },
+];
+
+export function LanguageSwitcher() {
+ const { t, i18n } = useTranslation();
+ const current = (i18n.language?.slice(0, 2) ?? "fr") as Locale;
+
+ const handleChange = (locale: Locale) => {
+ if (locale === current) return;
+ setLocale(locale);
+ toast.success(t("parametres.language.saved"));
+ };
+
+ return (
+
+
+
+
+
+
+
{t("parametres.language.title")}
+
+ {t("parametres.language.description")}
+
+
+
+ {OPTIONS.map((opt) => {
+ const active = opt.value === current;
+ return (
+ handleChange(opt.value)}
+ className={
+ "inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-[13.5px] font-medium transition-colors " +
+ (active
+ ? "border-rubis bg-rubis-glow text-rubis-deep"
+ : "border-line bg-white text-ink-2 hover:border-ink-3 hover:text-ink")
+ }
+ >
+ {opt.flag}
+ {t(opt.labelKey)}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts
new file mode 100644
index 0000000..ca8fd33
--- /dev/null
+++ b/apps/web/src/i18n/en.ts
@@ -0,0 +1,380 @@
+import type { Dict } from "./fr";
+
+export const en: Dict = {
+ common: {
+ save: "Save",
+ saving: "Saving…",
+ cancel: "Cancel",
+ delete: "Delete",
+ edit: "Edit",
+ confirm: "Confirm",
+ close: "Close",
+ back: "Back",
+ next: "Next",
+ previous: "Previous",
+ submit: "Submit",
+ loading: "Loading…",
+ error: "Error",
+ success: "Success",
+ yes: "Yes",
+ no: "No",
+ optional: "optional",
+ required: "required",
+ search: "Search",
+ filter: "Filter",
+ add: "Add",
+ create: "Create",
+ update: "Update",
+ open: "Open",
+ copy: "Copy",
+ copied: "Copied",
+ export: "Export",
+ import: "Import",
+ download: "Download",
+ refresh: "Refresh",
+ retry: "Retry",
+ moreActions: "More actions",
+ select: "Select",
+ seeAll: "See all",
+ seeMore: "See more",
+ seeLess: "See less",
+ },
+ errors: {
+ fallbackTitle: "Something went wrong.",
+ fallbackBody: "We've logged it. Reload the page to try again.",
+ fallbackCta: "Reload",
+ networkError: "Network error. Check your connection.",
+ unknownError: "An unexpected error occurred.",
+ notFoundTitle: "Page not found",
+ notFoundBody: "This page doesn't exist or no longer exists.",
+ notFoundCta: "Back to dashboard",
+ },
+ nav: {
+ dashboard: "Dashboard",
+ factures: "Invoices",
+ clients: "Clients",
+ plans: "Chase plans",
+ insights: "Stats",
+ parametres: "Settings",
+ logout: "Sign out",
+ admin: "Admin",
+ adminBlog: "Blog admin",
+ skipToContent: "Skip to content",
+ },
+ account: {
+ badge: "Account",
+ plan: "Plan",
+ upgradeCta: "Upgrade plan",
+ settingsLink: "Settings",
+ helpLink: "Help",
+ },
+ auth: {
+ login: {
+ title: "Welcome back.",
+ subtitle: "Sign in to pick up where you left off.",
+ emailLabel: "Email",
+ emailPlaceholder: "you@company.com",
+ passwordLabel: "Password",
+ passwordPlaceholder: "•••••••••",
+ forgotPassword: "Forgot password?",
+ submit: "Sign in",
+ submitting: "Signing in…",
+ withGoogle: "Continue with Google",
+ withMicrosoft: "Continue with Microsoft",
+ orDivider: "or",
+ noAccount: "Don't have an account yet?",
+ signupLink: "Create an account",
+ sso: {
+ cancelled: "{{provider}} sign-in cancelled.",
+ expired: "Session expired. Sign in again.",
+ invalidState: "A verification failed. Try again.",
+ accountExists: "An account already exists with this email. Sign in first to link your account.",
+ unknown: "{{provider}} sign-in failed. Try again.",
+ },
+ providers: {
+ google: "Google",
+ microsoft: "Microsoft",
+ },
+ invalidCredentials: "Email or password incorrect.",
+ validation: {
+ emailRequired: "Email is required.",
+ emailInvalid: "Invalid email.",
+ passwordRequired: "Password is required.",
+ },
+ },
+ signup: {
+ title: "Create your account.",
+ subtitle: "30 days free, no credit card.",
+ emailLabel: "Work email",
+ emailPlaceholder: "you@company.com",
+ passwordLabel: "Password",
+ passwordPlaceholder: "At least 10 characters",
+ passwordHint: "At least 10 characters. Mix letters and numbers.",
+ submit: "Create my account",
+ submitting: "Creating…",
+ withGoogle: "Sign up with Google",
+ withMicrosoft: "Sign up with Microsoft",
+ orDivider: "or",
+ hasAccount: "Already have an account?",
+ loginLink: "Sign in",
+ terms: "By creating an account, you accept our <1>Terms of Service1> and <2>Privacy Policy2>.",
+ emailTaken: "An account already exists with this email.",
+ validation: {
+ emailRequired: "Email is required.",
+ emailInvalid: "Invalid email.",
+ passwordTooShort: "Password must be at least 10 characters.",
+ },
+ },
+ onboarding: {
+ stepLabel: "Step {{current}} of {{total}}",
+ account: {
+ title: "Welcome. A bit about you.",
+ subtitle: "To personalise your email signature and your dashboard.",
+ firstNameLabel: "First name",
+ lastNameLabel: "Last name",
+ nextCta: "Continue",
+ },
+ company: {
+ title: "Your company.",
+ subtitle: "This info will appear on your reminders. You can edit it later.",
+ nameLabel: "Company name",
+ sirenLabel: "SIREN (optional)",
+ addressLabel: "Address",
+ nextCta: "Continue",
+ },
+ signature: {
+ title: "Your email signature.",
+ subtitle: "Will appear at the bottom of every reminder. Edit freely.",
+ signatureLabel: "Signature",
+ signaturePlaceholder: "Best regards,\nYour name",
+ finishCta: "Finish",
+ finishing: "Setting up…",
+ },
+ },
+ },
+ dashboard: {
+ title: "Dashboard",
+ welcome: "Hi {{name}}.",
+ welcomeAnon: "Hi.",
+ rubisCount_one: "{{count}} ruby earned",
+ rubisCount_other: "{{count}} rubies earned",
+ rubisExplain: "≈ <1>{{hours}}1> you didn't spend chasing invoices.",
+ kpi: {
+ toCollect: "To collect",
+ overdue: "Overdue",
+ collected: "Collected this month",
+ dso: "DSO",
+ dsoUnit: "days",
+ },
+ cta: {
+ uploadInvoice: "Upload an invoice",
+ createInvoice: "Create an invoice",
+ seeAllInvoices: "See all invoices",
+ },
+ activity: {
+ title: "Recent activity",
+ empty: "No activity yet. Upload your first invoice to get started.",
+ },
+ overdue: {
+ title: "Overdue invoices",
+ empty: "All caught up. Nice work.",
+ seeAll: "See all",
+ },
+ },
+ factures: {
+ title: "Invoices",
+ subtitle_zero: "No invoices yet.",
+ subtitle_one: "1 invoice",
+ subtitle_other: "{{count}} invoices",
+ newCta: "New invoice",
+ importCta: "Import",
+ empty: {
+ title: "No invoices yet.",
+ body: "Upload your first invoice to start chasing automatically.",
+ uploadCta: "Upload an invoice",
+ createCta: "Create a native invoice",
+ },
+ filters: {
+ all: "All",
+ pending: "Pending",
+ chasing: "Chasing",
+ overdue: "Overdue",
+ paid: "Paid",
+ cancelled: "Cancelled",
+ status: "Status",
+ search: "Search (number, client, amount)",
+ },
+ columns: {
+ number: "Number",
+ client: "Client",
+ amount: "Amount",
+ dueDate: "Due date",
+ status: "Status",
+ plan: "Plan",
+ nextAction: "Next action",
+ },
+ status: {
+ draft: "Draft",
+ pending: "Pending",
+ chasing: "Chasing",
+ overdue: "Overdue",
+ paid: "Paid",
+ cancelled: "Cancelled",
+ },
+ },
+ clients: {
+ title: "Clients",
+ subtitle_zero: "No clients yet.",
+ subtitle_one: "1 record",
+ subtitle_other: "{{count}} records",
+ newCta: "New client",
+ empty: {
+ title: "All quiet on the client front.",
+ body: "Your clients will appear here as soon as you import or create your first invoice.",
+ },
+ columns: {
+ name: "Name",
+ email: "Email",
+ pendingInvoices: "Open invoices",
+ overdueInvoices: "Overdue",
+ lastReminder: "Last reminder",
+ },
+ create: {
+ title: "New client",
+ nameLabel: "Client name",
+ namePlaceholder: "ACME Inc.",
+ contactLabel: "Contact name",
+ contactPlaceholder: "Jane Doe",
+ emailLabel: "Email",
+ emailPlaceholder: "accounts@acme.com",
+ phoneLabel: "Phone (optional)",
+ addressLabel: "Address (optional)",
+ submit: "Create client",
+ submitting: "Creating…",
+ },
+ overdueLabel_one: "1 overdue invoice",
+ overdueLabel_other: "{{count}} overdue invoices",
+ },
+ plans: {
+ title: "Chase plans",
+ subtitle: "Your sending cadences — to chase politely, firmly, or anywhere in between.",
+ newCta: "New plan",
+ empty: {
+ title: "No custom plans yet.",
+ body: "You're using the default plans for now. Build a custom one when you're ready.",
+ },
+ defaultPlans: {
+ label: "Default plans",
+ },
+ customPlans: {
+ label: "Your plans",
+ },
+ columns: {
+ name: "Name",
+ steps: "Steps",
+ tone: "Tone",
+ activeInvoices: "Active invoices",
+ },
+ tone: {
+ polite: "Courteous",
+ firm: "Firm",
+ mixed: "Progressive",
+ },
+ },
+ parametres: {
+ title: "Settings",
+ subtitle: "Configure your account, your company, your preferences.",
+ sections: {
+ account: {
+ title: "Account",
+ description: "Email, password, active sessions.",
+ },
+ profile: {
+ title: "Profile",
+ description: "Your name, your email signature.",
+ },
+ company: {
+ title: "Company",
+ description: "Name, SIREN, address, bank details.",
+ },
+ billing: {
+ title: "Billing",
+ description: "Issuer identity, legal mentions, numbering.",
+ },
+ branding: {
+ title: "Brand",
+ description: "Logo and colour on your reminders.",
+ },
+ subscription: {
+ title: "Subscription",
+ description: "Plan, payment, Rubis invoices.",
+ },
+ bank: {
+ title: "Bank",
+ description: "Bank connection to auto-detect payments (coming soon).",
+ },
+ preferences: {
+ title: "Preferences",
+ description: "Language, notifications, time zone.",
+ },
+ data: {
+ title: "Data",
+ description: "Export, delete account.",
+ },
+ },
+ language: {
+ title: "Interface language",
+ description: "Choose the language used throughout the app.",
+ label: "Language",
+ fr: "Français",
+ en: "English",
+ saved: "Language updated.",
+ },
+ profile: {
+ firstNameLabel: "First name",
+ lastNameLabel: "Last name",
+ signatureLabel: "Email signature",
+ signatureHint: "Appears at the bottom of every reminder.",
+ },
+ account: {
+ emailLabel: "Email",
+ emailHint: "Used for sign-in and notifications.",
+ passwordTitle: "Password",
+ changePassword: "Change password",
+ currentPassword: "Current password",
+ newPassword: "New password",
+ confirmPassword: "Confirm new password",
+ sessionsTitle: "Active sessions",
+ logoutAll: "Sign out all sessions",
+ },
+ dangerZone: {
+ title: "Danger zone",
+ deleteAccount: "Delete my account",
+ deleteConfirm: "This action is irreversible. All your invoices, clients and data will be deleted.",
+ },
+ },
+ insights: {
+ title: "Stats",
+ subtitle: "Track Rubis's impact on your cash flow.",
+ period: {
+ label: "Period",
+ week: "Week",
+ month: "Month",
+ quarter: "Quarter",
+ year: "Year",
+ all: "All",
+ },
+ kpi: {
+ timeSaved: "Time freed up",
+ remindersSent: "Reminders sent",
+ paidInvoices: "Invoices collected",
+ avgDelay: "Average payment delay",
+ },
+ },
+ toasts: {
+ saved: "Changes saved.",
+ deleted: "Deleted.",
+ copied: "Copied to clipboard.",
+ error: "Something went wrong. Try again.",
+ },
+};
diff --git a/apps/web/src/i18n/fr.ts b/apps/web/src/i18n/fr.ts
new file mode 100644
index 0000000..37996ae
--- /dev/null
+++ b/apps/web/src/i18n/fr.ts
@@ -0,0 +1,386 @@
+/**
+ * Dictionnaire FR — source de vérité pour les types du module i18n SPA.
+ *
+ * Convention : clés en kebab-case ou camelCase, regroupées par surface
+ * (auth, nav, dashboard, factures, etc.). Placeholders i18next entre {{}}.
+ */
+export const fr = {
+ common: {
+ save: "Enregistrer",
+ saving: "Enregistrement…",
+ cancel: "Annuler",
+ delete: "Supprimer",
+ edit: "Modifier",
+ confirm: "Confirmer",
+ close: "Fermer",
+ back: "Retour",
+ next: "Suivant",
+ previous: "Précédent",
+ submit: "Valider",
+ loading: "Chargement…",
+ error: "Erreur",
+ success: "Succès",
+ yes: "Oui",
+ no: "Non",
+ optional: "facultatif",
+ required: "obligatoire",
+ search: "Rechercher",
+ filter: "Filtrer",
+ add: "Ajouter",
+ create: "Créer",
+ update: "Mettre à jour",
+ open: "Ouvrir",
+ copy: "Copier",
+ copied: "Copié",
+ export: "Exporter",
+ import: "Importer",
+ download: "Télécharger",
+ refresh: "Rafraîchir",
+ retry: "Réessayer",
+ moreActions: "Plus d'actions",
+ select: "Sélectionner",
+ seeAll: "Tout voir",
+ seeMore: "Voir plus",
+ seeLess: "Voir moins",
+ },
+ errors: {
+ fallbackTitle: "Quelque chose a coincé.",
+ fallbackBody: "On a noté, on regarde. Rechargez la page pour réessayer.",
+ fallbackCta: "Recharger",
+ networkError: "Erreur réseau. Vérifiez votre connexion.",
+ unknownError: "Une erreur inattendue s'est produite.",
+ notFoundTitle: "Page introuvable",
+ notFoundBody: "Cette page n'existe pas ou plus.",
+ notFoundCta: "Retour au tableau de bord",
+ },
+ nav: {
+ dashboard: "Tableau de bord",
+ factures: "Factures",
+ clients: "Clients",
+ plans: "Plans de relance",
+ insights: "Statistiques",
+ parametres: "Paramètres",
+ logout: "Se déconnecter",
+ admin: "Admin",
+ adminBlog: "Blog admin",
+ skipToContent: "Aller au contenu",
+ },
+ account: {
+ badge: "Compte",
+ plan: "Plan",
+ upgradeCta: "Passer au plan supérieur",
+ settingsLink: "Paramètres",
+ helpLink: "Aide",
+ },
+ auth: {
+ login: {
+ title: "Bon retour.",
+ subtitle: "Connectez-vous pour reprendre où vous en étiez.",
+ emailLabel: "Email",
+ emailPlaceholder: "vous@entreprise.fr",
+ passwordLabel: "Mot de passe",
+ passwordPlaceholder: "•••••••••",
+ forgotPassword: "Mot de passe oublié ?",
+ submit: "Se connecter",
+ submitting: "Connexion…",
+ withGoogle: "Continuer avec Google",
+ withMicrosoft: "Continuer avec Microsoft",
+ orDivider: "ou",
+ noAccount: "Pas encore de compte ?",
+ signupLink: "Créer un compte",
+ sso: {
+ cancelled: "Connexion {{provider}} annulée.",
+ expired: "Session expirée. Reconnectez-vous.",
+ invalidState: "Une vérification a échoué. Réessayez.",
+ accountExists: "Un compte existe déjà avec cet email. Connectez-vous d'abord pour lier votre compte.",
+ unknown: "La connexion {{provider}} a échoué. Réessayez.",
+ },
+ providers: {
+ google: "Google",
+ microsoft: "Microsoft",
+ },
+ invalidCredentials: "Email ou mot de passe incorrect.",
+ validation: {
+ emailRequired: "L'email est obligatoire.",
+ emailInvalid: "Email invalide.",
+ passwordRequired: "Le mot de passe est obligatoire.",
+ },
+ },
+ signup: {
+ title: "Créer votre compte.",
+ subtitle: "30 jours gratuits, sans carte bancaire.",
+ emailLabel: "Email professionnel",
+ emailPlaceholder: "vous@entreprise.fr",
+ passwordLabel: "Mot de passe",
+ passwordPlaceholder: "Au moins 10 caractères",
+ passwordHint: "Au moins 10 caractères, mélangez lettres et chiffres.",
+ submit: "Créer mon compte",
+ submitting: "Création…",
+ withGoogle: "S'inscrire avec Google",
+ withMicrosoft: "S'inscrire avec Microsoft",
+ orDivider: "ou",
+ hasAccount: "Déjà un compte ?",
+ loginLink: "Se connecter",
+ terms: "En créant un compte, vous acceptez nos <1>Conditions générales1> et notre <2>politique de confidentialité2>.",
+ emailTaken: "Un compte existe déjà avec cet email.",
+ validation: {
+ emailRequired: "L'email est obligatoire.",
+ emailInvalid: "Email invalide.",
+ passwordTooShort: "Le mot de passe doit faire au moins 10 caractères.",
+ },
+ },
+ onboarding: {
+ stepLabel: "Étape {{current}} sur {{total}}",
+ account: {
+ title: "Bienvenue. Quelques infos sur vous.",
+ subtitle: "Pour personnaliser votre signature email et votre dashboard.",
+ firstNameLabel: "Prénom",
+ lastNameLabel: "Nom",
+ nextCta: "Continuer",
+ },
+ company: {
+ title: "Votre entreprise.",
+ subtitle: "Ces infos apparaîtront sur vos relances. Vous pourrez les modifier plus tard.",
+ nameLabel: "Nom de l'entreprise",
+ sirenLabel: "SIREN (facultatif)",
+ addressLabel: "Adresse",
+ nextCta: "Continuer",
+ },
+ signature: {
+ title: "Votre signature d'email.",
+ subtitle: "Apparaîtra en bas de chaque relance. Vous pouvez l'éditer librement.",
+ signatureLabel: "Signature",
+ signaturePlaceholder: "Cordialement,\nVotre nom",
+ finishCta: "Terminer",
+ finishing: "Configuration…",
+ },
+ },
+ },
+ dashboard: {
+ title: "Tableau de bord",
+ welcome: "Bonjour {{name}}.",
+ welcomeAnon: "Bonjour.",
+ rubisCount_one: "{{count}} rubis gagné",
+ rubisCount_other: "{{count}} rubis gagnés",
+ rubisExplain: "≈ <1>{{hours}}1> que vous n'avez pas passées à relancer.",
+ kpi: {
+ toCollect: "À encaisser",
+ overdue: "En retard",
+ collected: "Encaissé ce mois",
+ dso: "DSO",
+ dsoUnit: "jours",
+ },
+ cta: {
+ uploadInvoice: "Importer une facture",
+ createInvoice: "Créer une facture",
+ seeAllInvoices: "Voir toutes les factures",
+ },
+ activity: {
+ title: "Activité récente",
+ empty: "Pas encore d'activité. Importez votre première facture pour démarrer.",
+ },
+ overdue: {
+ title: "Factures en retard",
+ empty: "Tout est à jour. Bravo.",
+ seeAll: "Voir tout",
+ },
+ },
+ factures: {
+ title: "Factures",
+ subtitle_zero: "Aucune facture pour l'instant.",
+ subtitle_one: "1 facture",
+ subtitle_other: "{{count}} factures",
+ newCta: "Nouvelle facture",
+ importCta: "Importer",
+ empty: {
+ title: "Aucune facture pour l'instant.",
+ body: "Importez votre première facture pour commencer à relancer automatiquement.",
+ uploadCta: "Importer une facture",
+ createCta: "Créer une facture native",
+ },
+ filters: {
+ all: "Toutes",
+ pending: "En attente",
+ chasing: "En relance",
+ overdue: "En retard",
+ paid: "Payées",
+ cancelled: "Annulées",
+ status: "Statut",
+ search: "Rechercher (numéro, client, montant)",
+ },
+ columns: {
+ number: "Numéro",
+ client: "Client",
+ amount: "Montant",
+ dueDate: "Échéance",
+ status: "Statut",
+ plan: "Plan",
+ nextAction: "Prochaine action",
+ },
+ status: {
+ draft: "Brouillon",
+ pending: "En attente",
+ chasing: "En relance",
+ overdue: "En retard",
+ paid: "Payée",
+ cancelled: "Annulée",
+ },
+ },
+ clients: {
+ title: "Clients",
+ subtitle_zero: "Aucun client pour l'instant.",
+ subtitle_one: "1 fiche",
+ subtitle_other: "{{count}} fiches",
+ newCta: "Nouveau client",
+ empty: {
+ title: "Tout est calme côté clients.",
+ body: "Vos clients apparaîtront ici dès que vous importerez ou créerez votre première facture.",
+ },
+ columns: {
+ name: "Nom",
+ email: "Email",
+ pendingInvoices: "Factures en cours",
+ overdueInvoices: "En retard",
+ lastReminder: "Dernière relance",
+ },
+ create: {
+ title: "Nouveau client",
+ nameLabel: "Nom du client",
+ namePlaceholder: "ACME SAS",
+ contactLabel: "Nom du contact",
+ contactPlaceholder: "Jean Dupont",
+ emailLabel: "Email",
+ emailPlaceholder: "compta@acme.fr",
+ phoneLabel: "Téléphone (facultatif)",
+ addressLabel: "Adresse (facultatif)",
+ submit: "Créer le client",
+ submitting: "Création…",
+ },
+ overdueLabel_one: "1 facture en retard",
+ overdueLabel_other: "{{count}} factures en retard",
+ },
+ plans: {
+ title: "Plans de relance",
+ subtitle: "Vos cadences d'envoi pour relancer poliment, fermement, ou tout entre les deux.",
+ newCta: "Nouveau plan",
+ empty: {
+ title: "Aucun plan personnalisé.",
+ body: "Vous utilisez encore les plans fournis par défaut. Créez-en un sur mesure quand vous serez prêt.",
+ },
+ defaultPlans: {
+ label: "Plans par défaut",
+ },
+ customPlans: {
+ label: "Vos plans",
+ },
+ columns: {
+ name: "Nom",
+ steps: "Étapes",
+ tone: "Ton",
+ activeInvoices: "Factures actives",
+ },
+ tone: {
+ polite: "Courtois",
+ firm: "Ferme",
+ mixed: "Progressif",
+ },
+ },
+ parametres: {
+ title: "Paramètres",
+ subtitle: "Configurez votre compte, votre entreprise, vos préférences.",
+ sections: {
+ account: {
+ title: "Compte",
+ description: "Email, mot de passe, sessions actives.",
+ },
+ profile: {
+ title: "Profil",
+ description: "Votre nom, votre signature email.",
+ },
+ company: {
+ title: "Entreprise",
+ description: "Nom, SIREN, adresse, RIB.",
+ },
+ billing: {
+ title: "Facturation",
+ description: "Identité émetteur, mentions légales, numérotation.",
+ },
+ branding: {
+ title: "Marque",
+ description: "Logo et couleur sur vos relances.",
+ },
+ subscription: {
+ title: "Abonnement",
+ description: "Plan, paiement, factures Rubis.",
+ },
+ bank: {
+ title: "Banque",
+ description: "Connexion bancaire pour détecter les paiements (bientôt).",
+ },
+ preferences: {
+ title: "Préférences",
+ description: "Langue, notifications, fuseau horaire.",
+ },
+ data: {
+ title: "Données",
+ description: "Export, suppression de compte.",
+ },
+ },
+ language: {
+ title: "Langue de l'interface",
+ description: "Choisissez la langue utilisée dans toute l'application.",
+ label: "Langue",
+ fr: "Français",
+ en: "English",
+ saved: "Langue mise à jour.",
+ },
+ profile: {
+ firstNameLabel: "Prénom",
+ lastNameLabel: "Nom",
+ signatureLabel: "Signature email",
+ signatureHint: "Apparaît en bas de chaque relance.",
+ },
+ account: {
+ emailLabel: "Email",
+ emailHint: "Utilisé pour la connexion et les notifications.",
+ passwordTitle: "Mot de passe",
+ changePassword: "Changer le mot de passe",
+ currentPassword: "Mot de passe actuel",
+ newPassword: "Nouveau mot de passe",
+ confirmPassword: "Confirmer le nouveau mot de passe",
+ sessionsTitle: "Sessions actives",
+ logoutAll: "Déconnecter toutes les sessions",
+ },
+ dangerZone: {
+ title: "Zone dangereuse",
+ deleteAccount: "Supprimer mon compte",
+ deleteConfirm: "Cette action est irréversible. Toutes vos factures, clients et données seront supprimés.",
+ },
+ },
+ insights: {
+ title: "Statistiques",
+ subtitle: "Suivez l'impact de Rubis sur votre trésorerie.",
+ period: {
+ label: "Période",
+ week: "Semaine",
+ month: "Mois",
+ quarter: "Trimestre",
+ year: "Année",
+ all: "Tout",
+ },
+ kpi: {
+ timeSaved: "Temps libéré",
+ remindersSent: "Relances envoyées",
+ paidInvoices: "Factures encaissées",
+ avgDelay: "Délai moyen de paiement",
+ },
+ },
+ toasts: {
+ saved: "Modifications enregistrées.",
+ deleted: "Supprimé.",
+ copied: "Copié dans le presse-papiers.",
+ error: "Une erreur s'est produite. Réessayez.",
+ },
+};
+
+export type Dict = typeof fr;
diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts
new file mode 100644
index 0000000..a5949b3
--- /dev/null
+++ b/apps/web/src/i18n/index.ts
@@ -0,0 +1,63 @@
+/**
+ * Setup i18next + react-i18next pour l'app SPA.
+ *
+ * Locale détectée dans l'ordre :
+ * 1. localStorage["rubis:locale"] (préférence explicite de l'user)
+ * 2. navigator.language (fallback intelligent au 1er load)
+ * 3. DEFAULT_LOCALE = "fr"
+ *
+ * Le module exporte `i18n` (instance) et `setLocale(locale)` qui combine
+ * `i18n.changeLanguage` + persistance localStorage + mise à jour ``.
+ */
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+
+import { fr } from "./fr";
+import { en } from "./en";
+import { DEFAULT_LOCALE, LOCALES, STORAGE_KEY, isLocale, type Locale } from "./types";
+
+function detectInitialLocale(): Locale {
+ if (typeof window === "undefined") return DEFAULT_LOCALE;
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (isLocale(stored)) return stored;
+ const browser = window.navigator.language?.slice(0, 2).toLowerCase();
+ if (isLocale(browser)) return browser;
+ return DEFAULT_LOCALE;
+}
+
+const initialLocale = detectInitialLocale();
+
+void i18n.use(initReactI18next).init({
+ resources: {
+ fr: { translation: fr },
+ en: { translation: en },
+ },
+ lng: initialLocale,
+ fallbackLng: DEFAULT_LOCALE,
+ supportedLngs: [...LOCALES],
+ interpolation: {
+ escapeValue: false,
+ },
+ returnNull: false,
+});
+
+if (typeof document !== "undefined") {
+ document.documentElement.lang = initialLocale;
+}
+
+export function setLocale(locale: Locale): void {
+ void i18n.changeLanguage(locale);
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(STORAGE_KEY, locale);
+ }
+ if (typeof document !== "undefined") {
+ document.documentElement.lang = locale;
+ }
+}
+
+export function getCurrentLocale(): Locale {
+ return isLocale(i18n.language) ? i18n.language : DEFAULT_LOCALE;
+}
+
+export { i18n, DEFAULT_LOCALE, LOCALES };
+export type { Locale };
diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts
new file mode 100644
index 0000000..6b34dcf
--- /dev/null
+++ b/apps/web/src/i18n/types.ts
@@ -0,0 +1,17 @@
+/**
+ * Type-safe i18n pour l'app SPA Rubis.
+ *
+ * Convention identique à apps/landing : FR fait foi (`Dict` inféré de fr.ts),
+ * EN doit matcher la même shape (TS le force via la signature de `en`).
+ */
+export type Locale = "fr" | "en";
+
+export const LOCALES: readonly Locale[] = ["fr", "en"] as const;
+
+export const DEFAULT_LOCALE: Locale = "fr";
+
+export const STORAGE_KEY = "rubis:locale";
+
+export function isLocale(value: unknown): value is Locale {
+ return typeof value === "string" && (LOCALES as readonly string[]).includes(value);
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index e0c6606..e4df1d5 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,11 +1,14 @@
// Sentry init AVANT tout autre import non-essentiel pour capturer même
// les erreurs de bootstrap (cf. apps/web/src/lib/sentry.ts).
import "./lib/sentry";
+// i18n init AVANT le 1er render pour que les chaînes soient résolues dès le bootstrap.
+import "./i18n";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import * as Sentry from "@sentry/react";
import { routeTree } from "./routeTree.gen";
@@ -85,18 +88,17 @@ async function bootstrapSession(): Promise {
}
function FallbackError() {
+ const { t } = useTranslation();
return (
-
Quelque chose a coincé.
-
- On a noté, on regarde. Rechargez la page pour réessayer.
-
+
{t("errors.fallbackTitle")}
+
{t("errors.fallbackBody")}
location.reload()}
className="bg-rubis hover:bg-rubis-deep text-white px-5 py-2.5 rounded-md font-medium transition-colors"
>
- Recharger
+ {t("errors.fallbackCta")}
diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx
index d359231..65a3bf8 100644
--- a/apps/web/src/routes/_app/index.tsx
+++ b/apps/web/src/routes/_app/index.tsx
@@ -1,5 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { Camera, Plus, ArrowDownRight, ArrowRight } from "lucide-react";
import { api } from "@/lib/api";
@@ -74,6 +75,7 @@ export const Route = createFileRoute("/_app/")({
function DashboardPage() {
const manual = useManualInvoice();
+ const { t } = useTranslation();
const { data: kpis } = useQuery({
queryKey: queryKeys.dashboard.kpis(),
queryFn: () => api.get("/api/v1/dashboard/kpis"),
@@ -100,7 +102,7 @@ function DashboardPage() {
- Photo de facture
+ {t("dashboard.cta.uploadInvoice")}
- Saisir
+ {t("dashboard.cta.createInvoice")}
@@ -120,51 +122,29 @@ function DashboardPage() {
/>
1 ? "s" : ""} aujourd'hui`
- : undefined
- }
intent="neutral"
/>
1 ? "s" : ""} en demeure à valider`
- : undefined
- }
intent="warning"
/>
DSO moyen
- }
- value={`${kpis?.dsoDays ?? 0} j`}
- delta={
- kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
- ? `${
- kpis.dsoDeltaDays < 0 ? "↘" : "↗"
- } ${Math.abs(kpis.dsoDeltaDays)} j depuis Rubis`
- : undefined
+ {t("dashboard.kpi.dso")}
}
+ value={`${kpis?.dsoDays ?? 0} ${t("dashboard.kpi.dsoUnit")}`}
intent={kpis && kpis.dsoDeltaDays < 0 ? "positive" : "neutral"}
/>
@@ -184,7 +164,7 @@ function DashboardPage() {
to="/insights"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
- Détails →
+ {t("common.seeMore")} →
- Détails →
+ {t("common.seeMore")} →
-
Pipeline factures
+
{t("nav.factures")}
- Voir
+ {t("common.seeAll")}
@@ -248,7 +228,7 @@ function DashboardPage() {
{/* Petite signature visuelle en bas — discret, juste pour aérer. */}
- Vos factures se relancent en silence pendant que vous travaillez.
+ {t("dashboard.subtitle", { defaultValue: t("dashboard.activity.empty") })}
);
diff --git a/apps/web/src/routes/_app/parametres.tsx b/apps/web/src/routes/_app/parametres.tsx
index 066f910..66154ab 100644
--- a/apps/web/src/routes/_app/parametres.tsx
+++ b/apps/web/src/routes/_app/parametres.tsx
@@ -1,4 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
+import { useTranslation } from "react-i18next";
import { ArrowRight, CreditCard, FileText, Palette } from "lucide-react";
import { z } from "zod";
@@ -9,6 +10,7 @@ import { SignatureForm } from "@/components/settings/SignatureForm";
import { BankingSection } from "@/components/settings/BankingSection";
import { DangerZone } from "@/components/settings/DangerZone";
import { DemoToggle } from "@/components/demo/DemoToggle";
+import { LanguageSwitcher } from "@/components/settings/LanguageSwitcher";
import { Button } from "@rubis/ui";
import { Card } from "@rubis/ui";
import { useSubscription } from "@/lib/billing";
@@ -44,6 +46,7 @@ export const Route = createFileRoute("/_app/parametres")({
* du blast radius (modifier sa signature ne sauvegarde pas l'org, etc.).
*/
function ParametresPage() {
+ const { t } = useTranslation();
const { data: sub } = useSubscription();
const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free";
const search = Route.useSearch();
@@ -64,69 +67,62 @@ function ParametresPage() {
- Paramètres
+ {t("parametres.title")}
-
- Compte, entreprise, signature email — modifiables à tout moment.
-
+ {t("parametres.subtitle")}
+
+
+
+
- Comment vous signez
- >
- }
- description="Apposée à la fin de chaque relance que Rubis envoie pour vous. Cinq lignes max, ton sobre — c'est l'image qu'on laisse à votre client."
+ eyebrow={t("parametres.profile.signatureLabel")}
+ title={t("parametres.profile.signatureLabel")}
+ description={t("parametres.profile.signatureHint")}
>
- Plan & facturation
- >
- }
- description="Votre plan courant, votre limite de factures actives, et l'accès au portail Stripe pour gérer la CB et l'annulation."
+ eyebrow={t("parametres.sections.subscription.title")}
+ title={t("parametres.sections.subscription.title")}
+ description={t("parametres.sections.subscription.description")}
>
- Plan actuel
+ {t("account.plan")}
Rubis {planLabel}
- {sub?.inGracePeriod && (
-
- · 3 mois offerts
-
- )}
- Gérer l'abonnement
+ {t("common.edit")}
@@ -134,27 +130,23 @@ function ParametresPage() {
- Vos factures, votre identité
- >
- }
- description="Identité émetteur, RIB, mentions légales et thème par défaut pour les factures que vous créez dans Rubis. Mis à jour à la prochaine émission."
+ eyebrow={t("parametres.sections.billing.title")}
+ title={t("parametres.sections.billing.title")}
+ description={t("parametres.sections.billing.description")}
>
- Éditeur de factures
+ {t("parametres.sections.billing.title")}
- Paramétrer la facturation
+ {t("parametres.sections.billing.description")}
- Configurer
+ {t("common.edit")}
@@ -162,29 +154,25 @@ function ParametresPage() {
- Vos emails à votre image
- >
- }
- description="Logo, nom expéditeur et couleurs personnalisables sur les emails de relance envoyés à vos clients. Disponible sur le plan Business."
+ eyebrow={t("parametres.sections.branding.title")}
+ title={t("parametres.sections.branding.title")}
+ description={t("parametres.sections.branding.description")}
>
- Marque blanche
+ {t("parametres.sections.branding.title")}
{sub?.plan === "business"
- ? "Personnaliser le branding email"
- : "Réservé au plan Business"}
+ ? t("parametres.sections.branding.title")
+ : t("parametres.sections.branding.description")}
- {sub?.plan === "business" ? "Configurer" : "En savoir plus"}
+ {t("common.edit")}
@@ -193,23 +181,9 @@ function ParametresPage() {
{showBanking && (
- Bientôt : votre banque connectée à Rubis
- >
- ) : (
- <>
- Connecter votre banque
- >
- )
- }
- description={
- bankingStatus?.comingSoon
- ? "Nous finalisons notre agrément AISP avec Powens. Une fois ouvert, Rubis lira vos virements entrants pour détecter automatiquement les factures payées — en lecture seule, sans déplacement de fonds."
- : "Rubis lit vos virements entrants pour détecter automatiquement les factures payées. Lecture seule, aucun déplacement de fonds. Disponible sur les plans Pro et Business."
- }
+ eyebrow={t("parametres.sections.bank.title")}
+ title={t("parametres.sections.bank.title")}
+ description={t("parametres.sections.bank.description")}
>
- Faire vivre Rubis en accéléré
- >
- }
- description="Mode démo : horloge virtuelle qui avance dans le temps, emails capturés au lieu d'être envoyés à de vrais clients. Idéal pour montrer Rubis à un prospect."
+ eyebrow={t("nav.admin")}
+ title={t("nav.admin")}
+ description={t("nav.admin")}
>
)}
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx
index 534e03d..ece01d4 100644
--- a/apps/web/src/routes/login.tsx
+++ b/apps/web/src/routes/login.tsx
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ArrowRight } from "lucide-react";
import { z } from "zod";
@@ -29,19 +30,11 @@ const searchSchema = z.object({
microsoft: ssoErrorEnum,
});
-const SSO_ERROR_MESSAGES: Record> = {
- google: {
- denied: "Connexion Google annulée.",
- state_mismatch: "Session expirée, réessayez la connexion Google.",
- error: "Connexion Google impossible. Réessayez dans un instant.",
- no_email: "Votre compte Google n'a pas d'email associé.",
- },
- microsoft: {
- denied: "Connexion Microsoft annulée.",
- state_mismatch: "Session expirée, réessayez la connexion Microsoft.",
- error: "Connexion Microsoft impossible. Réessayez dans un instant.",
- no_email: "Votre compte Microsoft n'a pas d'email associé.",
- },
+const SSO_ERROR_KEY: Record = {
+ denied: "cancelled",
+ state_mismatch: "expired",
+ error: "unknown",
+ no_email: "unknown",
};
export const Route = createFileRoute("/login")({
@@ -53,15 +46,21 @@ function LoginPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const posthog = usePostHog();
+ const { t } = useTranslation();
// Toast d'erreur si on revient d'un échec SSO (?google=denied, ?microsoft=…).
useEffect(() => {
for (const provider of ["google", "microsoft"] as const) {
const code = search[provider];
- if (code && SSO_ERROR_MESSAGES[provider]?.[code]) {
- toast.error(SSO_ERROR_MESSAGES[provider]![code]!);
+ if (code && SSO_ERROR_KEY[code]) {
+ toast.error(
+ t(`auth.login.sso.${SSO_ERROR_KEY[code]}`, {
+ provider: t(`auth.login.providers.${provider}`),
+ }),
+ );
}
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [search.google, search.microsoft]);
const loginMutation = useMutation({
@@ -74,17 +73,16 @@ function LoginPage() {
});
posthog.capture("user_logged_in", { provider: "email" });
authStore.setSession(session.accessToken, session.user);
- toast.success(`Bonjour ${session.user.fullName.split(" ")[0]}.`);
- // Si on a une URL de redirection (depuis le guard d'auth), on la suit ;
- // sinon / qui décide quoi faire selon l'état d'onboarding.
+ const firstName = session.user.fullName.split(" ")[0];
+ toast.success(t("dashboard.welcome", { name: firstName }));
void navigate({ to: search.redirect ?? "/" });
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.status === 401) {
- toast.error("Email ou mot de passe incorrect.");
+ toast.error(t("auth.login.invalidCredentials"));
return;
}
- toast.error("Connexion impossible. Réessayez dans un instant.");
+ toast.error(t("errors.unknownError"));
},
});
@@ -100,7 +98,6 @@ function LoginPage() {
return (
- {/* Glow rubis discret en haut-droite — signature visuelle (cohérent landing). */}
- {/* Colonne gauche — message marketing.
- Décalée, dense, du caractère. Pas une carte centrée et fade. */}
- Bon retour
+ {t("auth.login.title")}
- Vos factures vous attendent .
-
- On reprend où vous en étiez.
+ {t("auth.login.title")}
- Connectez-vous pour voir où en sont vos relances, qui a payé,
- et combien de temps Rubis vous a fait gagner cette semaine.
+ {t("auth.login.subtitle")}
- Hébergement souverain
+ {t("nav.skipToContent")}
@@ -145,19 +137,18 @@ function LoginPage() {
- {/* Colonne droite — formulaire de connexion */}
- Se connecter
+ {t("auth.login.submit")}
- Pas encore de compte ?{" "}
+ {t("auth.login.noAccount")}{" "}
- Créer un compte
+ {t("auth.login.signupLink")}
@@ -178,7 +169,7 @@ function LoginPage() {
{(field) => (
@@ -188,7 +179,7 @@ function LoginPage() {
type="email"
autoComplete="email"
autoFocus
- placeholder="vous@entreprise.fr"
+ placeholder={t("auth.login.emailPlaceholder")}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
@@ -201,7 +192,7 @@ function LoginPage() {
{(field) => (
@@ -226,12 +217,12 @@ function LoginPage() {
loading={loginMutation.isPending}
className="mt-1 w-full"
>
- Continuer
+ {t("auth.login.submit")}
- Mot de passe oublié ?
+ {t("auth.login.forgotPassword")}
diff --git a/apps/web/src/routes/onboarding/compte.tsx b/apps/web/src/routes/onboarding/compte.tsx
index 233b328..67a10fe 100644
--- a/apps/web/src/routes/onboarding/compte.tsx
+++ b/apps/web/src/routes/onboarding/compte.tsx
@@ -1,6 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
import { ArrowRight } from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
@@ -31,18 +32,18 @@ export const Route = createFileRoute("/onboarding/compte")({
function OnboardingCompte() {
const navigate = useNavigate();
const { user } = useAuth();
+ const { t } = useTranslation();
const updateProfile = useMutation({
mutationFn: async (input: AccountInput) =>
api.patch("/api/v1/account/profile", input),
onSuccess: (updatedUser) => {
- // On garde le token courant, on rafraîchit juste le user.
const token = authStore.token;
if (token) authStore.setSession(token, updatedUser);
void navigate({ to: "/onboarding/entreprise" });
},
onError: () => {
- toast.error("On n'a pas pu enregistrer. Réessayez dans un instant.");
+ toast.error(t("errors.unknownError"));
},
});
@@ -59,13 +60,12 @@ function OnboardingCompte() {
return (
-
Étape 1
+
{t("auth.onboarding.stepLabel", { current: 1, total: 3 })}
- Vous, en deux lignes .
+ {t("auth.onboarding.account.title")}
- Ce qui apparaîtra sur les emails de relance que Rubis enverra pour
- vous. Modifiable plus tard depuis vos paramètres.
+ {t("auth.onboarding.account.subtitle")}
diff --git a/apps/web/src/routes/signup.tsx b/apps/web/src/routes/signup.tsx
index 70966a0..53cb669 100644
--- a/apps/web/src/routes/signup.tsx
+++ b/apps/web/src/routes/signup.tsx
@@ -1,6 +1,7 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
+import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { ArrowRight } from "lucide-react";
import { usePostHog } from "@posthog/react";
@@ -29,6 +30,7 @@ export const Route = createFileRoute("/signup")({
function SignupPage() {
const navigate = useNavigate();
const posthog = usePostHog();
+ const { t } = useTranslation();
const signupMutation = useMutation({
mutationFn: async (input: RegisterInput) =>
@@ -40,18 +42,18 @@ function SignupPage() {
});
posthog.capture("user_signed_up", { email: session.user.email });
authStore.setSession(session.accessToken, session.user);
- toast.success("Compte créé. On finalise votre installation.");
+ toast.success(t("toasts.saved"));
void navigate({ to: "/onboarding/compte" });
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.status === 422) {
const emailErrs = error.fieldErrors?.["email"];
if (emailErrs?.[0]) {
- toast.error(emailErrs[0]);
+ toast.error(t("auth.signup.emailTaken"));
return;
}
}
- toast.error("Inscription impossible. Réessayez dans un instant.");
+ toast.error(t("errors.unknownError"));
},
});
@@ -65,7 +67,6 @@ function SignupPage() {
return (
- {/* Glow rubis discret en haut-droite — signature visuelle. */}
- {/* Colonne gauche — pitch */}
- Bienvenue chez Rubis
+ {t("auth.signup.title")}
- Vos factures relancées toutes seules pendant que vous
- travaillez.
+ {t("auth.signup.title")}
- Trois minutes pour configurer, et Rubis prend le relais. Pas de
- CRM à apprendre, pas de tableur à entretenir.
+ {t("auth.signup.subtitle")}
-
- 5 h récupérées {" "}
- par semaine en moyenne — temps réinjecté dans votre vrai métier.
-
-
-
-
-
- 3 clics pour
- lancer une relance sur une facture neuve.
-
-
-
-
-
- 14 jours offerts {" "}
- au lancement, sans carte bancaire.
-
+ {t("auth.signup.subtitle")}
- {/* Colonne droite — formulaire */}
- Créer mon compte
+ {t("auth.signup.submit")}
- Déjà inscrit ?{" "}
+ {t("auth.signup.hasAccount")}{" "}
- Connexion
+ {t("auth.signup.loginLink")}
@@ -152,7 +132,7 @@ function SignupPage() {
{(field) => (
@@ -175,9 +155,8 @@ function SignupPage() {
{(field) => (
field.handleChange(e.target.value)}
@@ -198,9 +177,9 @@ function SignupPage() {
{(field) => (
field.handleChange(e.target.value)}
@@ -224,19 +203,17 @@ function SignupPage() {
loading={signupMutation.isPending}
className="mt-1 w-full"
>
- Créer mon compte
+ {t("auth.signup.submit")}
- En créant un compte, vous acceptez nos{" "}
-
- conditions d'utilisation
- {" "}
- et notre{" "}
-
- politique de confidentialité
-
- .
+ ,
+ 2: ,
+ }}
+ />
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0a4cb53..36103f8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -304,6 +304,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
+ i18next:
+ specifier: ^26.2.0
+ version: 26.2.0(typescript@6.0.3)
lucide-react:
specifier: ^0.475.0
version: 0.475.0(react@19.2.5)
@@ -316,6 +319,9 @@ importers:
react-dom:
specifier: ^19.2.5
version: 19.2.5(react@19.2.5)
+ react-i18next:
+ specifier: ^17.0.8
+ version: 17.0.8(i18next@26.2.0(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1)
@@ -5592,6 +5598,9 @@ packages:
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -5640,6 +5649,14 @@ packages:
hyphen@1.14.1:
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
+ i18next@26.2.0:
+ resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
ical-generator@10.2.0:
resolution: {integrity: sha512-XR5FsiDWCsz5MwBwMA/sQqR3A9H240xkXIeXOabV7uNAiieP+TA9rleVvlwPLRXMz+CXME8cGuDd7cdnE5At6w==}
engines: {node: 20 || 22 || >=24}
@@ -6913,6 +6930,22 @@ packages:
peerDependencies:
react: ^19.2.5
+ react-i18next@17.0.8:
+ resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -8063,6 +8096,10 @@ packages:
jsdom:
optional: true
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
volar-service-css@0.0.70:
resolution: {integrity: sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==}
peerDependencies:
@@ -14037,6 +14074,10 @@ snapshots:
html-escaper@3.0.3: {}
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
@@ -14095,6 +14136,10 @@ snapshots:
hyphen@1.14.1: {}
+ i18next@26.2.0(typescript@6.0.3):
+ optionalDependencies:
+ typescript: 6.0.3
+
ical-generator@10.2.0(@types/luxon@3.7.1)(@types/node@25.6.0)(dayjs@1.11.20)(luxon@3.7.2):
optionalDependencies:
'@types/luxon': 3.7.1
@@ -15498,6 +15543,17 @@ snapshots:
react: 19.2.5
scheduler: 0.27.0
+ react-i18next@17.0.8(i18next@26.2.0(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 26.2.0(typescript@6.0.3)
+ react: 19.2.5
+ use-sync-external-store: 1.6.0(react@19.2.5)
+ optionalDependencies:
+ react-dom: 19.2.5(react@19.2.5)
+ typescript: 6.0.3
+
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -16733,6 +16789,8 @@ snapshots:
- tsx
- yaml
+ void-elements@3.1.0: {}
+
volar-service-css@0.0.70(@volar/language-service@2.4.28):
dependencies:
vscode-css-languageservice: 6.3.10