diff --git a/apps/web/package.json b/apps/web/package.json index f072be5..642df19 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,12 +40,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "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", "sonner": "^1.7.4", "tailwind-merge": "^3.0.1", diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx index f61e23a..741e269 100644 --- a/apps/web/src/components/layout/AppSidebar.tsx +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -1,6 +1,5 @@ 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, @@ -38,7 +37,6 @@ 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). @@ -80,37 +78,37 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) } - label={t("nav.dashboard")} + label="Tableau de bord" collapsed={collapsed} /> } - label={t("nav.factures")} + label="Factures" collapsed={collapsed} /> } - label={t("nav.plans")} + label="Plans de relance" collapsed={collapsed} /> } - label={t("nav.clients")} + label="Clients" collapsed={collapsed} /> } - label={t("nav.insights")} + label="Statistiques" collapsed={collapsed} /> } - label={t("nav.parametres")} + label="Paramètres" collapsed={collapsed} /> @@ -119,7 +117,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) } - label={t("nav.adminBlog")} + label="Blog admin" collapsed={collapsed} /> )} @@ -131,7 +129,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) ) : (

- {t("dashboard.kpi.toCollect")} + Rubis ce mois

@@ -148,8 +146,8 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) diff --git a/apps/web/src/components/settings/LanguageSwitcher.tsx b/apps/web/src/components/settings/LanguageSwitcher.tsx deleted file mode 100644 index b27399a..0000000 --- a/apps/web/src/components/settings/LanguageSwitcher.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index ca8fd33..0000000 --- a/apps/web/src/i18n/en.ts +++ /dev/null @@ -1,380 +0,0 @@ -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 deleted file mode 100644 index 37996ae..0000000 --- a/apps/web/src/i18n/fr.ts +++ /dev/null @@ -1,386 +0,0 @@ -/** - * 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 deleted file mode 100644 index a5949b3..0000000 --- a/apps/web/src/i18n/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6b34dcf..0000000 --- a/apps/web/src/i18n/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 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 e4df1d5..7627029 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,14 +1,11 @@ // 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"; @@ -88,17 +85,16 @@ async function bootstrapSession(): Promise { } function FallbackError() { - const { t } = useTranslation(); return (
-

{t("errors.fallbackTitle")}

-

{t("errors.fallbackBody")}

+

Quelque chose a coincé.

+

On a noté, on regarde. Rechargez la page pour réessayer.

diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx index 65a3bf8..d145385 100644 --- a/apps/web/src/routes/_app/index.tsx +++ b/apps/web/src/routes/_app/index.tsx @@ -1,6 +1,5 @@ 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"; @@ -75,7 +74,6 @@ 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"), @@ -102,7 +100,7 @@ function DashboardPage() {
@@ -122,29 +120,29 @@ function DashboardPage() { />
{t("dashboard.kpi.dso")} + DSO } - value={`${kpis?.dsoDays ?? 0} ${t("dashboard.kpi.dsoUnit")}`} + value={`${kpis?.dsoDays ?? 0} jours`} intent={kpis && kpis.dsoDeltaDays < 0 ? "positive" : "neutral"} />
@@ -164,7 +162,7 @@ function DashboardPage() { to="/insights" className="text-[12px] text-rubis hover:underline underline-offset-4" > - {t("common.seeMore")} → + Voir plus →
- {t("common.seeMore")} → + Voir plus →
- {t("nav.factures")} + Factures - {t("common.seeAll")} + Tout voir
@@ -228,7 +226,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 66154ab..c088cff 100644 --- a/apps/web/src/routes/_app/parametres.tsx +++ b/apps/web/src/routes/_app/parametres.tsx @@ -1,5 +1,4 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { useTranslation } from "react-i18next"; import { ArrowRight, CreditCard, FileText, Palette } from "lucide-react"; import { z } from "zod"; @@ -10,7 +9,6 @@ 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"; @@ -46,7 +44,6 @@ 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(); @@ -67,53 +64,47 @@ function ParametresPage() {

- {t("parametres.title")} + Paramètres

-

{t("parametres.subtitle")}

+

+ Configurez votre compte, votre entreprise, vos préférences. +

- - - -

- {t("account.plan")} + Plan

Rubis {planLabel} @@ -122,7 +113,7 @@ function ParametresPage() { @@ -130,23 +121,23 @@ function ParametresPage() {

- {t("parametres.sections.billing.title")} + Facturation

- {t("parametres.sections.billing.description")} + Identité émetteur, mentions légales, numérotation.

@@ -154,25 +145,25 @@ function ParametresPage() {

- {t("parametres.sections.branding.title")} + Marque

{sub?.plan === "business" - ? t("parametres.sections.branding.title") - : t("parametres.sections.branding.description")} + ? "Marque" + : "Logo et couleur sur vos relances."}

@@ -181,9 +172,9 @@ function ParametresPage() { {showBanking && ( )} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index ece01d4..5bffb0e 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -2,7 +2,6 @@ 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"; @@ -30,11 +29,16 @@ const searchSchema = z.object({ microsoft: ssoErrorEnum, }); -const SSO_ERROR_KEY: Record = { - denied: "cancelled", - state_mismatch: "expired", - error: "unknown", - no_email: "unknown", +const PROVIDER_LABEL: Record<"google" | "microsoft", string> = { + google: "Google", + microsoft: "Microsoft", +}; + +const SSO_ERROR_MESSAGE: Record string> = { + denied: (p) => `Connexion ${p} annulée.`, + state_mismatch: () => "Session expirée. Reconnectez-vous.", + error: (p) => `La connexion ${p} a échoué. Réessayez.`, + no_email: (p) => `La connexion ${p} a échoué. Réessayez.`, }; export const Route = createFileRoute("/login")({ @@ -46,18 +50,14 @@ 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_KEY[code]) { - toast.error( - t(`auth.login.sso.${SSO_ERROR_KEY[code]}`, { - provider: t(`auth.login.providers.${provider}`), - }), - ); + const messageBuilder = code ? SSO_ERROR_MESSAGE[code] : undefined; + if (messageBuilder) { + toast.error(messageBuilder(PROVIDER_LABEL[provider])); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -74,15 +74,15 @@ function LoginPage() { posthog.capture("user_logged_in", { provider: "email" }); authStore.setSession(session.accessToken, session.user); const firstName = session.user.fullName.split(" ")[0]; - toast.success(t("dashboard.welcome", { name: firstName })); + toast.success(`Bonjour ${firstName}.`); void navigate({ to: search.redirect ?? "/" }); }, onError: (error: unknown) => { if (error instanceof ApiError && error.status === 401) { - toast.error(t("auth.login.invalidCredentials")); + toast.error("Email ou mot de passe incorrect."); return; } - toast.error(t("errors.unknownError")); + toast.error("Une erreur inattendue s'est produite."); }, }); @@ -113,18 +113,18 @@ function LoginPage() { - {t("auth.login.title")} + Bon retour.

- {t("auth.login.title")} + Bon retour.

- {t("auth.login.subtitle")} + Connectez-vous pour reprendre où vous en étiez.

  • - {t("nav.skipToContent")} + Aller au contenu