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 */} 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 ( + + ); + })} +
+
+
+
+ ); +} 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 Service and <2>Privacy Policy.", + 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}} 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érales et notre <2>politique de confidentialité.", + 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}} 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")}

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() {
@@ -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. */}

); 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 - - )}

@@ -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")}

@@ -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")}

@@ -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). */}