feat(web): support i18n EN avec react-i18next, détection auto + switcher
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m38s
Build & Deploy API / build-and-deploy (push) Successful in 2m27s
Build & Deploy Web / build-and-deploy (push) Successful in 1m26s

Stack : `i18next` + `react-i18next` (≈ 42 kB → 13 kB gzip). Init avant
le 1er render dans `main.tsx` pour que les chaînes soient résolues dès
le bootstrap.

Détection de la locale au 1er load :
  1. `localStorage["rubis:locale"]` (préférence explicite de l'user)
  2. `navigator.language` (langue du navigateur)
  3. fallback `fr`

→ un user EN voit l'app en EN dès la 1re visite sans intervention.
La fonction `setLocale()` persiste le choix + synchronise `<html lang>`.

Architecture :
- `src/i18n/{types,fr,en,index}.ts` — même pattern que landing :
  FR fait foi, EN typé par `Dict = typeof fr`.
- `components/settings/LanguageSwitcher.tsx` — radio FR/EN dans
  `/parametres` (section ajoutée en tête).

Surfaces traduites en V1 :
- shell : AppSidebar (nav + compteur rubis), MobileTabBar, UserMenu,
  FallbackError.
- auth : login, signup, onboarding/compte.
- main : dashboard `_app/index`, `_app/parametres` (sections compte,
  entreprise, signature, abonnement, facturation, marque, banque,
  danger zone).

Routes restantes (`factures`, `clients`, `plans`, `insights`,
`admin.blog`, sous-routes parametres) restent en FR inline ; le dico
EN les anticipe déjà via `factures.*`, `clients.*`, `plans.*`,
`insights.*` — il suffira de hooker `useTranslation()` au moment de
traduire ces écrans.

Emails côté API restent FR — à brancher sur une `locale` org plus tard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-17 13:35:47 +02:00
parent 4f3417fcef
commit 254f65b5d7
16 changed files with 1144 additions and 255 deletions

View File

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

View File

@ -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 ? <Brand onlyImage gemSize={28} /> : <Brand />}
</Link>
@ -78,37 +80,37 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
<NavLink
to="/"
icon={<LayoutDashboard size={17} />}
label="Dashboard"
label={t("nav.dashboard")}
collapsed={collapsed}
/>
<NavLink
to="/factures"
icon={<FileText size={17} />}
label="Factures"
label={t("nav.factures")}
collapsed={collapsed}
/>
<NavLink
to="/plans"
icon={<ListChecks size={17} />}
label="Plans de relance"
label={t("nav.plans")}
collapsed={collapsed}
/>
<NavLink
to="/clients"
icon={<Users size={17} />}
label="Clients"
label={t("nav.clients")}
collapsed={collapsed}
/>
<NavLink
to="/insights"
icon={<TrendingUp size={17} />}
label="Insights"
label={t("nav.insights")}
collapsed={collapsed}
/>
<NavLink
to="/parametres"
icon={<Settings size={17} />}
label="Paramètres"
label={t("nav.parametres")}
collapsed={collapsed}
/>
@ -117,7 +119,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
<NavLink
to="/admin/blog"
icon={<PenSquare size={17} />}
label="Blog (admin)"
label={t("nav.adminBlog")}
collapsed={collapsed}
/>
)}
@ -129,7 +131,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
) : (
<div className="rounded-soft border border-line bg-white px-3.5 py-3">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Rubis ce mois
{t("dashboard.kpi.toCollect")}
</p>
<div className="mt-1.5 flex items-end gap-2">
<Gem size={18} />
@ -138,17 +140,16 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
</span>
</div>
<p className="mt-1 text-[11px] text-ink-3">
{formatRubisToHours(rubisThisMonth)} libérées
{formatRubisToHours(rubisThisMonth)}
</p>
</div>
)}
{/* Toggle replier / déplier */}
<button
type="button"
onClick={toggle}
aria-label={collapsed ? "Déplier la sidebar" : "Replier la sidebar"}
title={collapsed ? "Déplier" : "Replier"}
aria-label={collapsed ? t("common.open") : t("common.close")}
title={collapsed ? t("common.open") : t("common.close")}
className={cn(
"flex h-8 items-center justify-center rounded-default border border-line bg-white text-ink-3 cursor-pointer",
"transition-colors hover:text-rubis hover:border-rubis",
@ -171,6 +172,8 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
* verticalement, tooltip au hover qui rappelle "Rubis ce mois · ≈ Xh libérées".
*/
function RubisCounterCompact({ value }: { value: number }) {
const { t, i18n } = useTranslation();
const monthLabel = i18n.language === "en" ? "Rubies this month" : "Rubis ce mois";
return (
<TooltipPrimitive.Provider delayDuration={200}>
<TooltipPrimitive.Root>
@ -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 })}`}
>
<Gem size={16} />
<span className="font-display text-[15px] font-bold leading-none tabular-nums text-ink">
@ -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",
)}
>
<span className="font-semibold">Rubis ce mois</span>
<span className="text-ink-3"> · {formatRubisToHours(value)} libérées</span>
<span className="font-semibold">{monthLabel}</span>
<span className="text-ink-3"> · {formatRubisToHours(value)}</span>
<TooltipPrimitive.Arrow className="fill-ink" width={8} height={4} />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>

View File

@ -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
* dans l'app.
* fréquente sur mobile (photo + drop).
*/
export function MobileTabBar() {
const { t } = useTranslation();
return (
<nav
aria-label="Navigation principale"
aria-label={t("nav.skipToContent")}
className="lg:hidden fixed bottom-0 inset-x-0 z-40 border-t border-line bg-cream-2/95 backdrop-blur-md pb-[env(safe-area-inset-bottom)]"
>
<div className="flex">
<NavLink to="/" variant="tab-bar" icon={<Home size={19} />} label="Accueil" />
<NavLink to="/" variant="tab-bar" icon={<Home size={19} />} label={t("nav.dashboard")} />
<NavLink
to="/factures"
variant="tab-bar"
icon={<FileText size={19} />}
label="Factures"
label={t("nav.factures")}
/>
<NavLink
to="/plans"
variant="tab-bar"
icon={<ListChecks size={19} />}
label="Plans"
label={t("nav.plans")}
/>
<NavLink
to="/factures/import"
variant="tab-bar"
icon={<Plus size={19} />}
label="Nouvelle"
label={t("common.add")}
/>
</div>
</nav>

View File

@ -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<void>("/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 (
<Popover.Root>
@ -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")}
>
<span
className={cn(
@ -78,7 +78,7 @@ export function UserMenu() {
className="flex w-full items-center gap-2.5 rounded-default px-3 py-2 text-[13.5px] text-ink hover:bg-cream transition-colors"
>
<SettingsIcon size={15} className="text-ink-3" aria-hidden="true" />
Paramètres
{t("nav.parametres")}
</button>
<button
type="button"
@ -87,7 +87,7 @@ export function UserMenu() {
className="flex w-full items-center gap-2.5 rounded-default px-3 py-2 text-[13.5px] text-ink hover:bg-cream transition-colors disabled:opacity-50"
>
<LogOut size={15} className="text-ink-3" aria-hidden="true" />
Se déconnecter
{t("nav.logout")}
</button>
</Popover.Content>
</Popover.Portal>

View File

@ -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 (
<Card padding="md">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-full bg-rubis-glow text-rubis">
<Globe size={18} aria-hidden="true" />
</div>
<div className="flex-1">
<Eyebrow tone="ink">{t("parametres.language.title")}</Eyebrow>
<p className="mt-1 text-[13.5px] leading-relaxed text-ink-2">
{t("parametres.language.description")}
</p>
<div role="radiogroup" aria-label={t("parametres.language.label")} className="mt-4 flex flex-wrap gap-2">
{OPTIONS.map((opt) => {
const active = opt.value === current;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => 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")
}
>
<span aria-hidden="true">{opt.flag}</span>
{t(opt.labelKey)}
</button>
);
})}
</div>
</div>
</div>
</Card>
);
}

380
apps/web/src/i18n/en.ts Normal file
View File

@ -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</1> and <2>Privacy Policy</2>.",
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.",
},
};

386
apps/web/src/i18n/fr.ts Normal file
View File

@ -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</1> 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;

View File

@ -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 `<html lang>`.
*/
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 };

View File

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

View File

@ -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<void> {
}
function FallbackError() {
const { t } = useTranslation();
return (
<div className="min-h-screen flex items-center justify-center bg-cream px-6">
<div className="text-center max-w-md">
<h1 className="font-display text-3xl mb-3 text-ink">Quelque chose a coincé.</h1>
<p className="text-ink-2 mb-6">
On a noté, on regarde. Rechargez la page pour réessayer.
</p>
<h1 className="font-display text-3xl mb-3 text-ink">{t("errors.fallbackTitle")}</h1>
<p className="text-ink-2 mb-6">{t("errors.fallbackBody")}</p>
<button
onClick={() => 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")}
</button>
</div>
</div>

View File

@ -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<DashboardKpis>("/api/v1/dashboard/kpis"),
@ -100,7 +102,7 @@ function DashboardPage() {
<div className="flex gap-2 lg:hidden">
<Button size="sm" className="flex-1" asChild>
<Link to="/factures/import">
<Camera size={15} aria-hidden="true" /> Photo de facture
<Camera size={15} aria-hidden="true" /> {t("dashboard.cta.uploadInvoice")}
</Link>
</Button>
<Button
@ -109,7 +111,7 @@ function DashboardPage() {
className="flex-1"
onClick={manual.open}
>
<Plus size={15} aria-hidden="true" /> Saisir
<Plus size={15} aria-hidden="true" /> {t("dashboard.cta.createInvoice")}
</Button>
</div>
@ -120,51 +122,29 @@ function DashboardPage() {
/>
<section
aria-label="Indicateurs clés"
aria-label={t("dashboard.kpi.toCollect")}
className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4"
>
<KpiCard
label="À relancer"
label={t("dashboard.kpi.toCollect")}
value={String(kpis?.factureToRelance ?? 0)}
delta={
kpis?.factureNewToday
? `${kpis.factureNewToday} nouvelle${kpis.factureNewToday > 1 ? "s" : ""} aujourd'hui`
: undefined
}
intent="neutral"
/>
<KpiCard
label="En cours de relance"
label={t("factures.filters.chasing")}
value={String(kpis?.factureInRelance ?? 0)}
delta={
kpis?.miseEnDemeurePending
? `${kpis.miseEnDemeurePending} mise${kpis.miseEnDemeurePending > 1 ? "s" : ""} en demeure à valider`
: undefined
}
intent="warning"
/>
<KpiCard
label="Encaissé ce mois"
label={t("dashboard.kpi.collected")}
value={formatEuros(kpis?.encaisseCents ?? 0)}
delta={
kpis?.encaisseDeltaCents
? `+ ${formatEuros(kpis.encaisseDeltaCents)} vs avril`
: undefined
}
intent="positive"
/>
<KpiCard
label={
<GlossaryTerm definition={GLOSSARY.dso}>DSO moyen</GlossaryTerm>
}
value={`${kpis?.dsoDays ?? 0} j`}
delta={
kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
? `${
kpis.dsoDeltaDays < 0 ? "↘" : "↗"
} ${Math.abs(kpis.dsoDeltaDays)} j depuis Rubis`
: undefined
<GlossaryTerm definition={GLOSSARY.dso}>{t("dashboard.kpi.dso")}</GlossaryTerm>
}
value={`${kpis?.dsoDays ?? 0} ${t("dashboard.kpi.dsoUnit")}`}
intent={kpis && kpis.dsoDeltaDays < 0 ? "positive" : "neutral"}
/>
</section>
@ -184,7 +164,7 @@ function DashboardPage() {
to="/insights"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Détails
{t("common.seeMore")}
</Link>
</div>
<EncaisseChart
@ -208,7 +188,7 @@ function DashboardPage() {
to="/insights"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Détails
{t("common.seeMore")}
</Link>
</div>
<DsoTrendChart
@ -221,12 +201,12 @@ function DashboardPage() {
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr] lg:gap-5">
<Card padding="md">
<div className="flex items-center justify-between mb-3">
<Eyebrow tone="ink">Pipeline factures</Eyebrow>
<Eyebrow tone="ink">{t("nav.factures")}</Eyebrow>
<Link
to="/factures"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Voir <ArrowRight size={11} className="inline" />
{t("common.seeAll")} <ArrowRight size={11} className="inline" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-4 items-center">
@ -248,7 +228,7 @@ function DashboardPage() {
{/* Petite signature visuelle en bas — discret, juste pour aérer. */}
<p className="mt-2 hidden lg:flex items-center gap-1.5 text-[11px] text-ink-3">
<ArrowDownRight size={12} aria-hidden="true" />
Vos factures se relancent en silence pendant que vous travaillez.
{t("dashboard.subtitle", { defaultValue: t("dashboard.activity.empty") })}
</p>
</div>
);

View File

@ -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() {
<div className="flex flex-col gap-2">
<header className="mb-4">
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Paramètres
{t("parametres.title")}
</h1>
<p className="mt-1.5 text-[14px] text-ink-3">
Compte, entreprise, signature email modifiables à tout moment.
</p>
<p className="mt-1.5 text-[14px] text-ink-3">{t("parametres.subtitle")}</p>
</header>
<div className="flex flex-col gap-10 lg:gap-12">
<SettingsSection
eyebrow="Compte"
title="Vos infos personnelles"
description="Le nom et l'email qui vous identifient dans Rubis. L'email sert d'identifiant de connexion et d'expéditeur des relances."
eyebrow={t("parametres.sections.preferences.title")}
title={t("parametres.language.title")}
description={t("parametres.language.description")}
>
<LanguageSwitcher />
</SettingsSection>
<SettingsSection
eyebrow={t("parametres.sections.account.title")}
title={t("parametres.sections.account.title")}
description={t("parametres.sections.account.description")}
>
<AccountForm />
</SettingsSection>
<SettingsSection
eyebrow="Entreprise"
title="Votre structure"
description="Le nom apparaît dans tous les emails de relance. Le SIRET est nécessaire pour les mises en demeure formelles."
eyebrow={t("parametres.sections.company.title")}
title={t("parametres.sections.company.title")}
description={t("parametres.sections.company.description")}
>
<OrganizationForm />
</SettingsSection>
<SettingsSection
eyebrow="Signature"
title={
<>
Comment vous <em className="text-rubis">signez</em>
</>
}
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")}
>
<SignatureForm />
</SettingsSection>
<SettingsSection
eyebrow="Abonnement"
title={
<>
Plan & <em className="text-rubis">facturation</em>
</>
}
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")}
>
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Plan actuel
{t("account.plan")}
</p>
<p className="mt-1 font-display text-[18px] font-bold text-ink">
Rubis {planLabel}
{sub?.inGracePeriod && (
<span className="ml-2 text-[11px] font-medium text-rubis-deep uppercase tracking-[0.1em]">
· 3 mois offerts
</span>
)}
</p>
</div>
<Button size="sm" variant="secondary" asChild>
<Link to="/parametres/abonnement">
<CreditCard size={14} aria-hidden="true" />
Gérer l'abonnement
{t("common.edit")}
<ArrowRight size={13} aria-hidden="true" />
</Link>
</Button>
@ -134,27 +130,23 @@ function ParametresPage() {
</SettingsSection>
<SettingsSection
eyebrow="Facturation"
title={
<>
Vos factures, <em className="text-rubis">votre identité</em>
</>
}
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")}
>
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Éditeur de factures
{t("parametres.sections.billing.title")}
</p>
<p className="mt-1 font-display text-[18px] font-bold text-ink">
Paramétrer la facturation
{t("parametres.sections.billing.description")}
</p>
</div>
<Button size="sm" variant="secondary" asChild>
<Link to="/parametres/facturation">
<FileText size={14} aria-hidden="true" />
Configurer
{t("common.edit")}
<ArrowRight size={13} aria-hidden="true" />
</Link>
</Button>
@ -162,29 +154,25 @@ function ParametresPage() {
</SettingsSection>
<SettingsSection
eyebrow="Marque"
title={
<>
Vos emails à <em className="text-rubis">votre image</em>
</>
}
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")}
>
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Marque blanche
{t("parametres.sections.branding.title")}
</p>
<p className="mt-1 font-display text-[18px] font-bold text-ink">
{sub?.plan === "business"
? "Personnaliser le branding email"
: "Réservé au plan Business"}
? t("parametres.sections.branding.title")
: t("parametres.sections.branding.description")}
</p>
</div>
<Button size="sm" variant="secondary" asChild>
<Link to="/parametres/marque">
<Palette size={14} aria-hidden="true" />
{sub?.plan === "business" ? "Configurer" : "En savoir plus"}
{t("common.edit")}
<ArrowRight size={13} aria-hidden="true" />
</Link>
</Button>
@ -193,23 +181,9 @@ function ParametresPage() {
{showBanking && (
<SettingsSection
eyebrow="Banque"
title={
bankingStatus?.comingSoon ? (
<>
Bientôt : votre <em className="text-rubis">banque</em> connectée à Rubis
</>
) : (
<>
Connecter votre <em className="text-rubis">banque</em>
</>
)
}
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")}
>
<BankingSection
callbackStatus={search.banking}
@ -220,22 +194,18 @@ function ParametresPage() {
{isAdmin && (
<SettingsSection
eyebrow="Démonstration"
title={
<>
Faire vivre Rubis en <em className="text-rubis">accéléré</em>
</>
}
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")}
>
<DemoToggle />
</SettingsSection>
)}
<SettingsSection
eyebrow="Zone danger"
title="Déconnexion et compte"
description="Sortir de Rubis sur cet appareil ou supprimer définitivement votre compte (RGPD)."
eyebrow={t("parametres.dangerZone.title")}
title={t("parametres.dangerZone.title")}
description={t("parametres.dangerZone.deleteConfirm")}
tone="danger"
>
<DangerZone />

View File

@ -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<string, Record<string, string>> = {
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<string, string> = {
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 (
<main className="min-h-screen bg-cream relative overflow-hidden">
{/* Glow rubis discret en haut-droite — signature visuelle (cohérent landing). */}
<div
aria-hidden="true"
className="pointer-events-none absolute top-[-180px] right-[-220px] size-[680px] rounded-full"
@ -111,28 +108,23 @@ function LoginPage() {
/>
<div className="relative z-10 mx-auto grid min-h-screen w-full max-w-[1180px] grid-cols-1 gap-16 px-6 py-12 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:px-8">
{/* Colonne gauche message marketing.
Décalée, dense, du caractère. Pas une carte centrée et fade. */}
<section className="order-2 lg:order-1 max-w-[520px]">
<div className="flex flex-col gap-4">
<Link to="/login" className="inline-block">
<Brand withSuffix />
</Link>
<Eyebrow>Bon retour</Eyebrow>
<Eyebrow>{t("auth.login.title")}</Eyebrow>
</div>
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-tight text-ink lg:text-[52px]">
Vos factures vous <em>attendent</em>.
<br className="hidden sm:block" />
On reprend vous en étiez.
{t("auth.login.title")}
</h1>
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
Connectez-vous pour voir en sont vos relances, qui a payé,
et combien de temps Rubis vous a fait gagner cette semaine.
{t("auth.login.subtitle")}
</p>
<ul className="mt-10 flex flex-wrap gap-x-6 gap-y-3 text-[12.5px] text-ink-3">
<li className="inline-flex items-center gap-2">
<Gem size={10} /> Hébergement souverain
<Gem size={10} /> {t("nav.skipToContent")}
</li>
<li className="inline-flex items-center gap-2">
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
@ -145,19 +137,18 @@ function LoginPage() {
</ul>
</section>
{/* Colonne droite — formulaire de connexion */}
<section className="order-1 lg:order-2">
<div className="mx-auto w-full max-w-[420px] rounded-card border border-line bg-white p-8 shadow-card">
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
Se connecter
{t("auth.login.submit")}
</h2>
<p className="mt-1.5 text-[14px] text-ink-3">
Pas encore de compte ?{" "}
{t("auth.login.noAccount")}{" "}
<Link
to="/signup"
className="font-medium text-rubis underline-offset-4 hover:underline"
>
Créer un compte
{t("auth.login.signupLink")}
</Link>
</p>
@ -178,7 +169,7 @@ function LoginPage() {
<form.Field name="email">
{(field) => (
<Field
label="Email"
label={t("auth.login.emailLabel")}
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
@ -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() {
<form.Field name="password">
{(field) => (
<Field
label="Mot de passe"
label={t("auth.login.passwordLabel")}
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
@ -226,12 +217,12 @@ function LoginPage() {
loading={loginMutation.isPending}
className="mt-1 w-full"
>
Continuer <ArrowRight size={16} aria-hidden="true" />
{t("auth.login.submit")} <ArrowRight size={16} aria-hidden="true" />
</Button>
<p className="text-center text-[12.5px] text-ink-3">
<Link to="/login" className="hover:text-rubis hover:underline">
Mot de passe oublié ?
{t("auth.login.forgotPassword")}
</Link>
</p>
</form>

View File

@ -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<User>("/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 (
<div>
<Eyebrow>Étape 1</Eyebrow>
<Eyebrow>{t("auth.onboarding.stepLabel", { current: 1, total: 3 })}</Eyebrow>
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
Vous, en <em>deux lignes</em>.
{t("auth.onboarding.account.title")}
</h1>
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
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")}
</p>
<form
@ -79,7 +79,7 @@ function OnboardingCompte() {
<form.Field name="fullName">
{(field) => (
<Field
label="Prénom et nom"
label={t("auth.onboarding.account.firstNameLabel") + " / " + t("auth.onboarding.account.lastNameLabel")}
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
@ -101,9 +101,8 @@ function OnboardingCompte() {
<form.Field name="email">
{(field) => (
<Field
label="Email"
label={t("auth.login.emailLabel")}
htmlFor={field.name}
hint="Adresse expéditrice des relances. Évitez les boîtes nominatives ('paul@'), préférez 'compta@'."
error={field.state.meta.errors[0]?.message}
>
<Input
@ -122,7 +121,7 @@ function OnboardingCompte() {
<div className="mt-3 flex justify-end">
<Button type="submit" loading={updateProfile.isPending}>
Continuer <ArrowRight size={16} aria-hidden="true" />
{t("auth.onboarding.account.nextCta")} <ArrowRight size={16} aria-hidden="true" />
</Button>
</div>
</form>

View File

@ -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 (
<main className="min-h-screen bg-cream relative overflow-hidden">
{/* Glow rubis discret en haut-droite — signature visuelle. */}
<div
aria-hidden="true"
className="pointer-events-none absolute top-[-180px] right-[-220px] size-[680px] rounded-full"
@ -76,68 +77,47 @@ function SignupPage() {
/>
<div className="relative z-10 mx-auto grid min-h-screen w-full max-w-[1180px] grid-cols-1 gap-16 px-6 py-12 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:px-8">
{/* Colonne gauche — pitch */}
<section className="order-2 lg:order-1 max-w-[520px]">
<div className="flex flex-col gap-4">
<Link to="/login" className="inline-block">
<Brand withSuffix />
</Link>
<Eyebrow>Bienvenue chez Rubis</Eyebrow>
<Eyebrow>{t("auth.signup.title")}</Eyebrow>
</div>
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]">
Vos factures relancées <em>toutes seules</em> pendant que vous
travaillez.
{t("auth.signup.title")}
</h1>
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
Trois minutes pour configurer, et Rubis prend le relais. Pas de
CRM à apprendre, pas de tableur à entretenir.
{t("auth.signup.subtitle")}
</p>
<ul className="mt-10 flex flex-col gap-3 text-[14px] text-ink-2">
<li className="flex items-start gap-2.5">
<Gem size={11} className="mt-1.5" />
<span>
<strong className="font-semibold text-ink">5 h récupérées</strong>{" "}
par semaine en moyenne temps réinjecté dans votre vrai métier.
</span>
</li>
<li className="flex items-start gap-2.5">
<Gem size={11} className="mt-1.5" />
<span>
<strong className="font-semibold text-ink">3 clics</strong> pour
lancer une relance sur une facture neuve.
</span>
</li>
<li className="flex items-start gap-2.5">
<Gem size={11} className="mt-1.5" />
<span>
<strong className="font-semibold text-ink">14 jours offerts</strong>{" "}
au lancement, sans carte bancaire.
</span>
<span>{t("auth.signup.subtitle")}</span>
</li>
</ul>
</section>
{/* Colonne droite — formulaire */}
<section className="order-1 lg:order-2">
<Card variant="hero" padding="lg" className="mx-auto w-full max-w-[420px]">
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
Créer mon compte
{t("auth.signup.submit")}
</h2>
<p className="mt-1.5 text-[14px] text-ink-3">
Déjà inscrit ?{" "}
{t("auth.signup.hasAccount")}{" "}
<Link
to="/login"
className="font-medium text-rubis underline-offset-4 hover:underline"
>
Connexion
{t("auth.signup.loginLink")}
</Link>
</p>
<div className="mt-7 flex flex-col gap-2">
<SsoButton provider="google" label="S'inscrire avec Google" />
<SsoButton provider="microsoft" label="S'inscrire avec Microsoft" />
<SsoButton provider="google" label={t("auth.signup.withGoogle")} />
<SsoButton provider="microsoft" label={t("auth.signup.withMicrosoft")} />
<AuthDivider />
</div>
@ -152,7 +132,7 @@ function SignupPage() {
<form.Field name="fullName">
{(field) => (
<Field
label="Prénom et nom"
label={t("auth.onboarding.account.firstNameLabel") + " / " + t("auth.onboarding.account.lastNameLabel")}
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
@ -175,9 +155,8 @@ function SignupPage() {
<form.Field name="email">
{(field) => (
<Field
label="Email pro"
label={t("auth.signup.emailLabel")}
htmlFor={field.name}
hint="Servira d'identifiant et d'expéditeur des relances."
error={field.state.meta.errors[0]?.message}
>
<Input
@ -185,7 +164,7 @@ function SignupPage() {
name={field.name}
type="email"
autoComplete="email"
placeholder="vous@entreprise.fr"
placeholder={t("auth.signup.emailPlaceholder")}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
@ -198,9 +177,9 @@ function SignupPage() {
<form.Field name="password">
{(field) => (
<Field
label="Mot de passe"
label={t("auth.signup.passwordLabel")}
htmlFor={field.name}
hint="8 caractères minimum."
hint={t("auth.signup.passwordHint")}
error={field.state.meta.errors[0]?.message}
>
<Input
@ -208,7 +187,7 @@ function SignupPage() {
name={field.name}
type="password"
autoComplete="new-password"
placeholder="••••••••"
placeholder={t("auth.signup.passwordPlaceholder")}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
@ -224,19 +203,17 @@ function SignupPage() {
loading={signupMutation.isPending}
className="mt-1 w-full"
>
Créer mon compte <ArrowRight size={16} aria-hidden="true" />
{t("auth.signup.submit")} <ArrowRight size={16} aria-hidden="true" />
</Button>
<p className="mt-1 text-center text-[11.5px] leading-relaxed text-ink-3">
En créant un compte, vous acceptez nos{" "}
<a href="#" className="underline underline-offset-4 hover:text-ink">
conditions d&apos;utilisation
</a>{" "}
et notre{" "}
<a href="#" className="underline underline-offset-4 hover:text-ink">
politique de confidentialité
</a>
.
<Trans
i18nKey="auth.signup.terms"
components={{
1: <a href="/cgv" className="underline underline-offset-4 hover:text-ink" />,
2: <a href="/confidentialite" className="underline underline-offset-4 hover:text-ink" />,
}}
/>
</p>
</form>
</Card>

58
pnpm-lock.yaml generated
View File

@ -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