refactor(web): retire i18n EN, app SPA mono-langue FR
Pendant de cecbddc (landing). Retire le système react-i18next ajouté en 254f65b — un seul brief utilisateur en français suffit pour l'audience TPE-PME française visée en V1. - Désinstalle i18next + react-i18next (≈ 13 kB gzip économisés). - Supprime apps/web/src/i18n/ (fr.ts, en.ts, index.ts, types.ts) et le LanguageSwitcher de /parametres. - Inline les chaînes FR dans les composants impactés : main.tsx (FallbackError), shell (AppSidebar, MobileTabBar, UserMenu), auth (login, signup, onboarding/compte), dashboard (_app/index), /parametres. - Met à jour signup : « 30 jours gratuits » → « 14 jours gratuits » pour s'aligner sur la landing. Côté UX visible : plus de switcher langue, plus de détection navigator.language, plus de localStorage["rubis:locale"]. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
cecbddc496
commit
7c0767f45e
@ -40,12 +40,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"i18next": "^26.2.0",
|
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"posthog-js": "^1.250.0",
|
"posthog-js": "^1.250.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-i18next": "^17.0.8",
|
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import {
|
import {
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
@ -38,7 +37,6 @@ const STORAGE_KEY = "rubis.sidebar.collapsed";
|
|||||||
*/
|
*/
|
||||||
export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
|
export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t } = useTranslation();
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
// Lecture localStorage à l'init (côté client uniquement).
|
// Lecture localStorage à l'init (côté client uniquement).
|
||||||
@ -80,37 +78,37 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
|
|||||||
<NavLink
|
<NavLink
|
||||||
to="/"
|
to="/"
|
||||||
icon={<LayoutDashboard size={17} />}
|
icon={<LayoutDashboard size={17} />}
|
||||||
label={t("nav.dashboard")}
|
label="Tableau de bord"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/factures"
|
to="/factures"
|
||||||
icon={<FileText size={17} />}
|
icon={<FileText size={17} />}
|
||||||
label={t("nav.factures")}
|
label="Factures"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/plans"
|
to="/plans"
|
||||||
icon={<ListChecks size={17} />}
|
icon={<ListChecks size={17} />}
|
||||||
label={t("nav.plans")}
|
label="Plans de relance"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/clients"
|
to="/clients"
|
||||||
icon={<Users size={17} />}
|
icon={<Users size={17} />}
|
||||||
label={t("nav.clients")}
|
label="Clients"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/insights"
|
to="/insights"
|
||||||
icon={<TrendingUp size={17} />}
|
icon={<TrendingUp size={17} />}
|
||||||
label={t("nav.insights")}
|
label="Statistiques"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/parametres"
|
to="/parametres"
|
||||||
icon={<Settings size={17} />}
|
icon={<Settings size={17} />}
|
||||||
label={t("nav.parametres")}
|
label="Paramètres"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -119,7 +117,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
|
|||||||
<NavLink
|
<NavLink
|
||||||
to="/admin/blog"
|
to="/admin/blog"
|
||||||
icon={<PenSquare size={17} />}
|
icon={<PenSquare size={17} />}
|
||||||
label={t("nav.adminBlog")}
|
label="Blog admin"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -131,7 +129,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
|
|||||||
) : (
|
) : (
|
||||||
<div className="rounded-soft border border-line bg-white px-3.5 py-3">
|
<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">
|
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
|
||||||
{t("dashboard.kpi.toCollect")}
|
Rubis ce mois
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1.5 flex items-end gap-2">
|
<div className="mt-1.5 flex items-end gap-2">
|
||||||
<Gem size={18} />
|
<Gem size={18} />
|
||||||
@ -148,8 +146,8 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={collapsed ? t("common.open") : t("common.close")}
|
aria-label={collapsed ? "Ouvrir" : "Fermer"}
|
||||||
title={collapsed ? t("common.open") : t("common.close")}
|
title={collapsed ? "Ouvrir" : "Fermer"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 items-center justify-center rounded-default border border-line bg-white text-ink-3 cursor-pointer",
|
"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",
|
"transition-colors hover:text-rubis hover:border-rubis",
|
||||||
@ -172,8 +170,7 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
|
|||||||
* verticalement, tooltip au hover qui rappelle "Rubis ce mois · ≈ Xh libérées".
|
* verticalement, tooltip au hover qui rappelle "Rubis ce mois · ≈ Xh libérées".
|
||||||
*/
|
*/
|
||||||
function RubisCounterCompact({ value }: { value: number }) {
|
function RubisCounterCompact({ value }: { value: number }) {
|
||||||
const { t, i18n } = useTranslation();
|
const monthLabel = "Rubis ce mois";
|
||||||
const monthLabel = i18n.language === "en" ? "Rubies this month" : "Rubis ce mois";
|
|
||||||
return (
|
return (
|
||||||
<TooltipPrimitive.Provider delayDuration={200}>
|
<TooltipPrimitive.Provider delayDuration={200}>
|
||||||
<TooltipPrimitive.Root>
|
<TooltipPrimitive.Root>
|
||||||
@ -184,7 +181,7 @@ function RubisCounterCompact({ value }: { value: number }) {
|
|||||||
"cursor-default",
|
"cursor-default",
|
||||||
)}
|
)}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={`${value} ${t("hero.mockRubis", { defaultValue: monthLabel })}`}
|
aria-label={`${value} ${monthLabel}`}
|
||||||
>
|
>
|
||||||
<Gem size={16} />
|
<Gem size={16} />
|
||||||
<span className="font-display text-[15px] font-bold leading-none tabular-nums text-ink">
|
<span className="font-display text-[15px] font-bold leading-none tabular-nums text-ink">
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Home, FileText, ListChecks, Plus } from "lucide-react";
|
import { Home, FileText, ListChecks, Plus } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { NavLink } from "./NavLink";
|
import { NavLink } from "./NavLink";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -9,31 +8,30 @@ import { NavLink } from "./NavLink";
|
|||||||
* fréquente sur mobile (photo + drop).
|
* fréquente sur mobile (photo + drop).
|
||||||
*/
|
*/
|
||||||
export function MobileTabBar() {
|
export function MobileTabBar() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label={t("nav.skipToContent")}
|
aria-label="Aller au contenu"
|
||||||
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)]"
|
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">
|
<div className="flex">
|
||||||
<NavLink to="/" variant="tab-bar" icon={<Home size={19} />} label={t("nav.dashboard")} />
|
<NavLink to="/" variant="tab-bar" icon={<Home size={19} />} label="Tableau de bord" />
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/factures"
|
to="/factures"
|
||||||
variant="tab-bar"
|
variant="tab-bar"
|
||||||
icon={<FileText size={19} />}
|
icon={<FileText size={19} />}
|
||||||
label={t("nav.factures")}
|
label="Factures"
|
||||||
/>
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/plans"
|
to="/plans"
|
||||||
variant="tab-bar"
|
variant="tab-bar"
|
||||||
icon={<ListChecks size={19} />}
|
icon={<ListChecks size={19} />}
|
||||||
label={t("nav.plans")}
|
label="Plans de relance"
|
||||||
/>
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/factures/import"
|
to="/factures/import"
|
||||||
variant="tab-bar"
|
variant="tab-bar"
|
||||||
icon={<Plus size={19} />}
|
icon={<Plus size={19} />}
|
||||||
label={t("common.add")}
|
label="Ajouter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ChevronDown, LogOut, Settings as SettingsIcon } from "lucide-react";
|
import { ChevronDown, LogOut, Settings as SettingsIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@ -15,13 +14,12 @@ import { cn } from "@/lib/utils";
|
|||||||
export function UserMenu() {
|
export function UserMenu() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => api.post<void>("/api/v1/account/logout"),
|
mutationFn: async () => api.post<void>("/api/v1/account/logout"),
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
authStore.clear();
|
authStore.clear();
|
||||||
toast.success(t("toasts.saved"));
|
toast.success("Modifications enregistrées.");
|
||||||
void navigate({ to: "/login" });
|
void navigate({ to: "/login" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -43,7 +41,7 @@ export function UserMenu() {
|
|||||||
"py-1 pl-1 pr-2.5 transition-colors hover:border-ink-3",
|
"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",
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
)}
|
)}
|
||||||
aria-label={t("account.badge")}
|
aria-label="Compte"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -78,7 +76,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"
|
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" />
|
<SettingsIcon size={15} className="text-ink-3" aria-hidden="true" />
|
||||||
{t("nav.parametres")}
|
Paramètres
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -87,7 +85,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"
|
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" />
|
<LogOut size={15} className="text-ink-3" aria-hidden="true" />
|
||||||
{t("nav.logout")}
|
Se déconnecter
|
||||||
</button>
|
</button>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Portal>
|
</Popover.Portal>
|
||||||
|
|||||||
@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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</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.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -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</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;
|
|
||||||
@ -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 `<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 };
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -1,14 +1,11 @@
|
|||||||
// Sentry init AVANT tout autre import non-essentiel pour capturer même
|
// Sentry init AVANT tout autre import non-essentiel pour capturer même
|
||||||
// les erreurs de bootstrap (cf. apps/web/src/lib/sentry.ts).
|
// les erreurs de bootstrap (cf. apps/web/src/lib/sentry.ts).
|
||||||
import "./lib/sentry";
|
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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
@ -88,17 +85,16 @@ async function bootstrapSession(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FallbackError() {
|
function FallbackError() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-cream px-6">
|
<div className="min-h-screen flex items-center justify-center bg-cream px-6">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<h1 className="font-display text-3xl mb-3 text-ink">{t("errors.fallbackTitle")}</h1>
|
<h1 className="font-display text-3xl mb-3 text-ink">Quelque chose a coincé.</h1>
|
||||||
<p className="text-ink-2 mb-6">{t("errors.fallbackBody")}</p>
|
<p className="text-ink-2 mb-6">On a noté, on regarde. Rechargez la page pour réessayer.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => location.reload()}
|
onClick={() => location.reload()}
|
||||||
className="bg-rubis hover:bg-rubis-deep text-white px-5 py-2.5 rounded-md font-medium transition-colors"
|
className="bg-rubis hover:bg-rubis-deep text-white px-5 py-2.5 rounded-md font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{t("errors.fallbackCta")}
|
Recharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Camera, Plus, ArrowDownRight, ArrowRight } from "lucide-react";
|
import { Camera, Plus, ArrowDownRight, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@ -75,7 +74,6 @@ export const Route = createFileRoute("/_app/")({
|
|||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const manual = useManualInvoice();
|
const manual = useManualInvoice();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: kpis } = useQuery({
|
const { data: kpis } = useQuery({
|
||||||
queryKey: queryKeys.dashboard.kpis(),
|
queryKey: queryKeys.dashboard.kpis(),
|
||||||
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
|
queryFn: () => api.get<DashboardKpis>("/api/v1/dashboard/kpis"),
|
||||||
@ -102,7 +100,7 @@ function DashboardPage() {
|
|||||||
<div className="flex gap-2 lg:hidden">
|
<div className="flex gap-2 lg:hidden">
|
||||||
<Button size="sm" className="flex-1" asChild>
|
<Button size="sm" className="flex-1" asChild>
|
||||||
<Link to="/factures/import">
|
<Link to="/factures/import">
|
||||||
<Camera size={15} aria-hidden="true" /> {t("dashboard.cta.uploadInvoice")}
|
<Camera size={15} aria-hidden="true" /> Importer une facture
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -111,7 +109,7 @@ function DashboardPage() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={manual.open}
|
onClick={manual.open}
|
||||||
>
|
>
|
||||||
<Plus size={15} aria-hidden="true" /> {t("dashboard.cta.createInvoice")}
|
<Plus size={15} aria-hidden="true" /> Créer une facture
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -122,29 +120,29 @@ function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label={t("dashboard.kpi.toCollect")}
|
aria-label="À encaisser"
|
||||||
className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4"
|
className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4"
|
||||||
>
|
>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t("dashboard.kpi.toCollect")}
|
label="À encaisser"
|
||||||
value={String(kpis?.factureToRelance ?? 0)}
|
value={String(kpis?.factureToRelance ?? 0)}
|
||||||
intent="neutral"
|
intent="neutral"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t("factures.filters.chasing")}
|
label="En relance"
|
||||||
value={String(kpis?.factureInRelance ?? 0)}
|
value={String(kpis?.factureInRelance ?? 0)}
|
||||||
intent="warning"
|
intent="warning"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t("dashboard.kpi.collected")}
|
label="Encaissé ce mois"
|
||||||
value={formatEuros(kpis?.encaisseCents ?? 0)}
|
value={formatEuros(kpis?.encaisseCents ?? 0)}
|
||||||
intent="positive"
|
intent="positive"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={
|
label={
|
||||||
<GlossaryTerm definition={GLOSSARY.dso}>{t("dashboard.kpi.dso")}</GlossaryTerm>
|
<GlossaryTerm definition={GLOSSARY.dso}>DSO</GlossaryTerm>
|
||||||
}
|
}
|
||||||
value={`${kpis?.dsoDays ?? 0} ${t("dashboard.kpi.dsoUnit")}`}
|
value={`${kpis?.dsoDays ?? 0} jours`}
|
||||||
intent={kpis && kpis.dsoDeltaDays < 0 ? "positive" : "neutral"}
|
intent={kpis && kpis.dsoDeltaDays < 0 ? "positive" : "neutral"}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@ -164,7 +162,7 @@ function DashboardPage() {
|
|||||||
to="/insights"
|
to="/insights"
|
||||||
className="text-[12px] text-rubis hover:underline underline-offset-4"
|
className="text-[12px] text-rubis hover:underline underline-offset-4"
|
||||||
>
|
>
|
||||||
{t("common.seeMore")} →
|
Voir plus →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<EncaisseChart
|
<EncaisseChart
|
||||||
@ -188,7 +186,7 @@ function DashboardPage() {
|
|||||||
to="/insights"
|
to="/insights"
|
||||||
className="text-[12px] text-rubis hover:underline underline-offset-4"
|
className="text-[12px] text-rubis hover:underline underline-offset-4"
|
||||||
>
|
>
|
||||||
{t("common.seeMore")} →
|
Voir plus →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<DsoTrendChart
|
<DsoTrendChart
|
||||||
@ -201,12 +199,12 @@ function DashboardPage() {
|
|||||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr] lg:gap-5">
|
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr] lg:gap-5">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<Eyebrow tone="ink">{t("nav.factures")}</Eyebrow>
|
<Eyebrow tone="ink">Factures</Eyebrow>
|
||||||
<Link
|
<Link
|
||||||
to="/factures"
|
to="/factures"
|
||||||
className="text-[12px] text-rubis hover:underline underline-offset-4"
|
className="text-[12px] text-rubis hover:underline underline-offset-4"
|
||||||
>
|
>
|
||||||
{t("common.seeAll")} <ArrowRight size={11} className="inline" />
|
Tout voir <ArrowRight size={11} className="inline" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-4 items-center">
|
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-4 items-center">
|
||||||
@ -228,7 +226,7 @@ function DashboardPage() {
|
|||||||
{/* Petite signature visuelle en bas — discret, juste pour aérer. */}
|
{/* 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">
|
<p className="mt-2 hidden lg:flex items-center gap-1.5 text-[11px] text-ink-3">
|
||||||
<ArrowDownRight size={12} aria-hidden="true" />
|
<ArrowDownRight size={12} aria-hidden="true" />
|
||||||
{t("dashboard.subtitle", { defaultValue: t("dashboard.activity.empty") })}
|
Pas encore d'activité. Importez votre première facture pour démarrer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ArrowRight, CreditCard, FileText, Palette } from "lucide-react";
|
import { ArrowRight, CreditCard, FileText, Palette } from "lucide-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@ -10,7 +9,6 @@ import { SignatureForm } from "@/components/settings/SignatureForm";
|
|||||||
import { BankingSection } from "@/components/settings/BankingSection";
|
import { BankingSection } from "@/components/settings/BankingSection";
|
||||||
import { DangerZone } from "@/components/settings/DangerZone";
|
import { DangerZone } from "@/components/settings/DangerZone";
|
||||||
import { DemoToggle } from "@/components/demo/DemoToggle";
|
import { DemoToggle } from "@/components/demo/DemoToggle";
|
||||||
import { LanguageSwitcher } from "@/components/settings/LanguageSwitcher";
|
|
||||||
import { Button } from "@rubis/ui";
|
import { Button } from "@rubis/ui";
|
||||||
import { Card } from "@rubis/ui";
|
import { Card } from "@rubis/ui";
|
||||||
import { useSubscription } from "@/lib/billing";
|
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.).
|
* du blast radius (modifier sa signature ne sauvegarde pas l'org, etc.).
|
||||||
*/
|
*/
|
||||||
function ParametresPage() {
|
function ParametresPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: sub } = useSubscription();
|
const { data: sub } = useSubscription();
|
||||||
const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free";
|
const planLabel = sub?.plan === "pro" ? "Pro" : sub?.plan === "business" ? "Business" : "Free";
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
@ -67,53 +64,47 @@ function ParametresPage() {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<header className="mb-4">
|
<header className="mb-4">
|
||||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
|
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
|
||||||
{t("parametres.title")}
|
Paramètres
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1.5 text-[14px] text-ink-3">{t("parametres.subtitle")}</p>
|
<p className="mt-1.5 text-[14px] text-ink-3">
|
||||||
|
Configurez votre compte, votre entreprise, vos préférences.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 lg:gap-12">
|
<div className="flex flex-col gap-10 lg:gap-12">
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.sections.preferences.title")}
|
eyebrow="Compte"
|
||||||
title={t("parametres.language.title")}
|
title="Compte"
|
||||||
description={t("parametres.language.description")}
|
description="Email, mot de passe, sessions actives."
|
||||||
>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
eyebrow={t("parametres.sections.account.title")}
|
|
||||||
title={t("parametres.sections.account.title")}
|
|
||||||
description={t("parametres.sections.account.description")}
|
|
||||||
>
|
>
|
||||||
<AccountForm />
|
<AccountForm />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.sections.company.title")}
|
eyebrow="Entreprise"
|
||||||
title={t("parametres.sections.company.title")}
|
title="Entreprise"
|
||||||
description={t("parametres.sections.company.description")}
|
description="Nom, SIREN, adresse, RIB."
|
||||||
>
|
>
|
||||||
<OrganizationForm />
|
<OrganizationForm />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.profile.signatureLabel")}
|
eyebrow="Signature email"
|
||||||
title={t("parametres.profile.signatureLabel")}
|
title="Signature email"
|
||||||
description={t("parametres.profile.signatureHint")}
|
description="Apparaît en bas de chaque relance."
|
||||||
>
|
>
|
||||||
<SignatureForm />
|
<SignatureForm />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.sections.subscription.title")}
|
eyebrow="Abonnement"
|
||||||
title={t("parametres.sections.subscription.title")}
|
title="Abonnement"
|
||||||
description={t("parametres.sections.subscription.description")}
|
description="Plan, paiement, factures Rubis."
|
||||||
>
|
>
|
||||||
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
|
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
||||||
{t("account.plan")}
|
Plan
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-display text-[18px] font-bold text-ink">
|
<p className="mt-1 font-display text-[18px] font-bold text-ink">
|
||||||
Rubis {planLabel}
|
Rubis {planLabel}
|
||||||
@ -122,7 +113,7 @@ function ParametresPage() {
|
|||||||
<Button size="sm" variant="secondary" asChild>
|
<Button size="sm" variant="secondary" asChild>
|
||||||
<Link to="/parametres/abonnement">
|
<Link to="/parametres/abonnement">
|
||||||
<CreditCard size={14} aria-hidden="true" />
|
<CreditCard size={14} aria-hidden="true" />
|
||||||
{t("common.edit")}
|
Modifier
|
||||||
<ArrowRight size={13} aria-hidden="true" />
|
<ArrowRight size={13} aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -130,23 +121,23 @@ function ParametresPage() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.sections.billing.title")}
|
eyebrow="Facturation"
|
||||||
title={t("parametres.sections.billing.title")}
|
title="Facturation"
|
||||||
description={t("parametres.sections.billing.description")}
|
description="Identité émetteur, mentions légales, numérotation."
|
||||||
>
|
>
|
||||||
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
|
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
||||||
{t("parametres.sections.billing.title")}
|
Facturation
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-display text-[18px] font-bold text-ink">
|
<p className="mt-1 font-display text-[18px] font-bold text-ink">
|
||||||
{t("parametres.sections.billing.description")}
|
Identité émetteur, mentions légales, numérotation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="secondary" asChild>
|
<Button size="sm" variant="secondary" asChild>
|
||||||
<Link to="/parametres/facturation">
|
<Link to="/parametres/facturation">
|
||||||
<FileText size={14} aria-hidden="true" />
|
<FileText size={14} aria-hidden="true" />
|
||||||
{t("common.edit")}
|
Modifier
|
||||||
<ArrowRight size={13} aria-hidden="true" />
|
<ArrowRight size={13} aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -154,25 +145,25 @@ function ParametresPage() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.sections.branding.title")}
|
eyebrow="Marque"
|
||||||
title={t("parametres.sections.branding.title")}
|
title="Marque"
|
||||||
description={t("parametres.sections.branding.description")}
|
description="Logo et couleur sur vos relances."
|
||||||
>
|
>
|
||||||
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
|
<Card padding="md" className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
|
||||||
{t("parametres.sections.branding.title")}
|
Marque
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-display text-[18px] font-bold text-ink">
|
<p className="mt-1 font-display text-[18px] font-bold text-ink">
|
||||||
{sub?.plan === "business"
|
{sub?.plan === "business"
|
||||||
? t("parametres.sections.branding.title")
|
? "Marque"
|
||||||
: t("parametres.sections.branding.description")}
|
: "Logo et couleur sur vos relances."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="secondary" asChild>
|
<Button size="sm" variant="secondary" asChild>
|
||||||
<Link to="/parametres/marque">
|
<Link to="/parametres/marque">
|
||||||
<Palette size={14} aria-hidden="true" />
|
<Palette size={14} aria-hidden="true" />
|
||||||
{t("common.edit")}
|
Modifier
|
||||||
<ArrowRight size={13} aria-hidden="true" />
|
<ArrowRight size={13} aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@ -181,9 +172,9 @@ function ParametresPage() {
|
|||||||
|
|
||||||
{showBanking && (
|
{showBanking && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.sections.bank.title")}
|
eyebrow="Banque"
|
||||||
title={t("parametres.sections.bank.title")}
|
title="Banque"
|
||||||
description={t("parametres.sections.bank.description")}
|
description="Connexion bancaire pour détecter les paiements (bientôt)."
|
||||||
>
|
>
|
||||||
<BankingSection
|
<BankingSection
|
||||||
callbackStatus={search.banking}
|
callbackStatus={search.banking}
|
||||||
@ -194,18 +185,18 @@ function ParametresPage() {
|
|||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("nav.admin")}
|
eyebrow="Admin"
|
||||||
title={t("nav.admin")}
|
title="Admin"
|
||||||
description={t("nav.admin")}
|
description="Admin"
|
||||||
>
|
>
|
||||||
<DemoToggle />
|
<DemoToggle />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
eyebrow={t("parametres.dangerZone.title")}
|
eyebrow="Zone dangereuse"
|
||||||
title={t("parametres.dangerZone.title")}
|
title="Zone dangereuse"
|
||||||
description={t("parametres.dangerZone.deleteConfirm")}
|
description="Cette action est irréversible. Toutes vos factures, clients et données seront supprimés."
|
||||||
tone="danger"
|
tone="danger"
|
||||||
>
|
>
|
||||||
<DangerZone />
|
<DangerZone />
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useEffect } from "react";
|
|||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -30,11 +29,16 @@ const searchSchema = z.object({
|
|||||||
microsoft: ssoErrorEnum,
|
microsoft: ssoErrorEnum,
|
||||||
});
|
});
|
||||||
|
|
||||||
const SSO_ERROR_KEY: Record<string, string> = {
|
const PROVIDER_LABEL: Record<"google" | "microsoft", string> = {
|
||||||
denied: "cancelled",
|
google: "Google",
|
||||||
state_mismatch: "expired",
|
microsoft: "Microsoft",
|
||||||
error: "unknown",
|
};
|
||||||
no_email: "unknown",
|
|
||||||
|
const SSO_ERROR_MESSAGE: Record<string, (provider: string) => 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")({
|
export const Route = createFileRoute("/login")({
|
||||||
@ -46,18 +50,14 @@ function LoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Toast d'erreur si on revient d'un échec SSO (?google=denied, ?microsoft=…).
|
// Toast d'erreur si on revient d'un échec SSO (?google=denied, ?microsoft=…).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const provider of ["google", "microsoft"] as const) {
|
for (const provider of ["google", "microsoft"] as const) {
|
||||||
const code = search[provider];
|
const code = search[provider];
|
||||||
if (code && SSO_ERROR_KEY[code]) {
|
const messageBuilder = code ? SSO_ERROR_MESSAGE[code] : undefined;
|
||||||
toast.error(
|
if (messageBuilder) {
|
||||||
t(`auth.login.sso.${SSO_ERROR_KEY[code]}`, {
|
toast.error(messageBuilder(PROVIDER_LABEL[provider]));
|
||||||
provider: t(`auth.login.providers.${provider}`),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -74,15 +74,15 @@ function LoginPage() {
|
|||||||
posthog.capture("user_logged_in", { provider: "email" });
|
posthog.capture("user_logged_in", { provider: "email" });
|
||||||
authStore.setSession(session.accessToken, session.user);
|
authStore.setSession(session.accessToken, session.user);
|
||||||
const firstName = session.user.fullName.split(" ")[0];
|
const firstName = session.user.fullName.split(" ")[0];
|
||||||
toast.success(t("dashboard.welcome", { name: firstName }));
|
toast.success(`Bonjour ${firstName}.`);
|
||||||
void navigate({ to: search.redirect ?? "/" });
|
void navigate({ to: search.redirect ?? "/" });
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
if (error instanceof ApiError && error.status === 401) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
toast.error(t("auth.login.invalidCredentials"));
|
toast.error("Email ou mot de passe incorrect.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error(t("errors.unknownError"));
|
toast.error("Une erreur inattendue s'est produite.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,18 +113,18 @@ function LoginPage() {
|
|||||||
<Link to="/login" className="inline-block">
|
<Link to="/login" className="inline-block">
|
||||||
<Brand withSuffix />
|
<Brand withSuffix />
|
||||||
</Link>
|
</Link>
|
||||||
<Eyebrow>{t("auth.login.title")}</Eyebrow>
|
<Eyebrow>Bon retour.</Eyebrow>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-tight text-ink lg:text-[52px]">
|
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-tight text-ink lg:text-[52px]">
|
||||||
{t("auth.login.title")}
|
Bon retour.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
|
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
|
||||||
{t("auth.login.subtitle")}
|
Connectez-vous pour reprendre où vous en étiez.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-10 flex flex-wrap gap-x-6 gap-y-3 text-[12.5px] text-ink-3">
|
<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">
|
<li className="inline-flex items-center gap-2">
|
||||||
<Gem size={10} /> {t("nav.skipToContent")}
|
<Gem size={10} /> Aller au contenu
|
||||||
</li>
|
</li>
|
||||||
<li className="inline-flex items-center gap-2">
|
<li className="inline-flex items-center gap-2">
|
||||||
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
|
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
|
||||||
@ -140,15 +140,15 @@ function LoginPage() {
|
|||||||
<section className="order-1 lg:order-2">
|
<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">
|
<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">
|
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
|
||||||
{t("auth.login.submit")}
|
Se connecter
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-[14px] text-ink-3">
|
<p className="mt-1.5 text-[14px] text-ink-3">
|
||||||
{t("auth.login.noAccount")}{" "}
|
Pas encore de compte ?{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/signup"
|
to="/signup"
|
||||||
className="font-medium text-rubis underline-offset-4 hover:underline"
|
className="font-medium text-rubis underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
{t("auth.login.signupLink")}
|
Créer un compte
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -169,7 +169,7 @@ function LoginPage() {
|
|||||||
<form.Field name="email">
|
<form.Field name="email">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field
|
<Field
|
||||||
label={t("auth.login.emailLabel")}
|
label="Email"
|
||||||
htmlFor={field.name}
|
htmlFor={field.name}
|
||||||
error={field.state.meta.errors[0]?.message}
|
error={field.state.meta.errors[0]?.message}
|
||||||
>
|
>
|
||||||
@ -179,7 +179,7 @@ function LoginPage() {
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={t("auth.login.emailPlaceholder")}
|
placeholder="vous@entreprise.fr"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
@ -192,7 +192,7 @@ function LoginPage() {
|
|||||||
<form.Field name="password">
|
<form.Field name="password">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field
|
<Field
|
||||||
label={t("auth.login.passwordLabel")}
|
label="Mot de passe"
|
||||||
htmlFor={field.name}
|
htmlFor={field.name}
|
||||||
error={field.state.meta.errors[0]?.message}
|
error={field.state.meta.errors[0]?.message}
|
||||||
>
|
>
|
||||||
@ -217,12 +217,12 @@ function LoginPage() {
|
|||||||
loading={loginMutation.isPending}
|
loading={loginMutation.isPending}
|
||||||
className="mt-1 w-full"
|
className="mt-1 w-full"
|
||||||
>
|
>
|
||||||
{t("auth.login.submit")} <ArrowRight size={16} aria-hidden="true" />
|
Se connecter <ArrowRight size={16} aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-center text-[12.5px] text-ink-3">
|
<p className="text-center text-[12.5px] text-ink-3">
|
||||||
<Link to="/login" className="hover:text-rubis hover:underline">
|
<Link to="/login" className="hover:text-rubis hover:underline">
|
||||||
{t("auth.login.forgotPassword")}
|
Mot de passe oublié ?
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -32,7 +31,6 @@ export const Route = createFileRoute("/onboarding/compte")({
|
|||||||
function OnboardingCompte() {
|
function OnboardingCompte() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const updateProfile = useMutation({
|
const updateProfile = useMutation({
|
||||||
mutationFn: async (input: AccountInput) =>
|
mutationFn: async (input: AccountInput) =>
|
||||||
@ -43,7 +41,7 @@ function OnboardingCompte() {
|
|||||||
void navigate({ to: "/onboarding/entreprise" });
|
void navigate({ to: "/onboarding/entreprise" });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t("errors.unknownError"));
|
toast.error("Une erreur inattendue s'est produite.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -60,12 +58,12 @@ function OnboardingCompte() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Eyebrow>{t("auth.onboarding.stepLabel", { current: 1, total: 3 })}</Eyebrow>
|
<Eyebrow>Étape 1 sur 3</Eyebrow>
|
||||||
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
|
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
|
||||||
{t("auth.onboarding.account.title")}
|
Bienvenue. Quelques infos sur vous.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
|
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
|
||||||
{t("auth.onboarding.account.subtitle")}
|
Pour personnaliser votre signature email et votre dashboard.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@ -79,7 +77,7 @@ function OnboardingCompte() {
|
|||||||
<form.Field name="fullName">
|
<form.Field name="fullName">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field
|
<Field
|
||||||
label={t("auth.onboarding.account.firstNameLabel") + " / " + t("auth.onboarding.account.lastNameLabel")}
|
label="Prénom / Nom"
|
||||||
htmlFor={field.name}
|
htmlFor={field.name}
|
||||||
error={field.state.meta.errors[0]?.message}
|
error={field.state.meta.errors[0]?.message}
|
||||||
>
|
>
|
||||||
@ -101,7 +99,7 @@ function OnboardingCompte() {
|
|||||||
<form.Field name="email">
|
<form.Field name="email">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field
|
<Field
|
||||||
label={t("auth.login.emailLabel")}
|
label="Email"
|
||||||
htmlFor={field.name}
|
htmlFor={field.name}
|
||||||
error={field.state.meta.errors[0]?.message}
|
error={field.state.meta.errors[0]?.message}
|
||||||
>
|
>
|
||||||
@ -121,7 +119,7 @@ function OnboardingCompte() {
|
|||||||
|
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<Button type="submit" loading={updateProfile.isPending}>
|
<Button type="submit" loading={updateProfile.isPending}>
|
||||||
{t("auth.onboarding.account.nextCta")} <ArrowRight size={16} aria-hidden="true" />
|
Continuer <ArrowRight size={16} aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { usePostHog } from "@posthog/react";
|
import { usePostHog } from "@posthog/react";
|
||||||
@ -30,7 +29,6 @@ export const Route = createFileRoute("/signup")({
|
|||||||
function SignupPage() {
|
function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const signupMutation = useMutation({
|
const signupMutation = useMutation({
|
||||||
mutationFn: async (input: RegisterInput) =>
|
mutationFn: async (input: RegisterInput) =>
|
||||||
@ -42,18 +40,18 @@ function SignupPage() {
|
|||||||
});
|
});
|
||||||
posthog.capture("user_signed_up", { email: session.user.email });
|
posthog.capture("user_signed_up", { email: session.user.email });
|
||||||
authStore.setSession(session.accessToken, session.user);
|
authStore.setSession(session.accessToken, session.user);
|
||||||
toast.success(t("toasts.saved"));
|
toast.success("Modifications enregistrées.");
|
||||||
void navigate({ to: "/onboarding/compte" });
|
void navigate({ to: "/onboarding/compte" });
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
if (error instanceof ApiError && error.status === 422) {
|
if (error instanceof ApiError && error.status === 422) {
|
||||||
const emailErrs = error.fieldErrors?.["email"];
|
const emailErrs = error.fieldErrors?.["email"];
|
||||||
if (emailErrs?.[0]) {
|
if (emailErrs?.[0]) {
|
||||||
toast.error(t("auth.signup.emailTaken"));
|
toast.error("Un compte existe déjà avec cet email.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.error(t("errors.unknownError"));
|
toast.error("Une erreur inattendue s'est produite.");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,19 +81,19 @@ function SignupPage() {
|
|||||||
<Brand withSuffix />
|
<Brand withSuffix />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Eyebrow>{t("auth.signup.title")}</Eyebrow>
|
<Eyebrow>Créer votre compte.</Eyebrow>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]">
|
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]">
|
||||||
{t("auth.signup.title")}
|
Créer votre compte.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
|
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
|
||||||
{t("auth.signup.subtitle")}
|
14 jours gratuits, sans engagement.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-10 flex flex-col gap-3 text-[14px] text-ink-2">
|
<ul className="mt-10 flex flex-col gap-3 text-[14px] text-ink-2">
|
||||||
<li className="flex items-start gap-2.5">
|
<li className="flex items-start gap-2.5">
|
||||||
<Gem size={11} className="mt-1.5" />
|
<Gem size={11} className="mt-1.5" />
|
||||||
<span>{t("auth.signup.subtitle")}</span>
|
<span>14 jours gratuits, sans engagement.</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@ -103,21 +101,21 @@ function SignupPage() {
|
|||||||
<section className="order-1 lg:order-2">
|
<section className="order-1 lg:order-2">
|
||||||
<Card variant="hero" padding="lg" className="mx-auto w-full max-w-[420px]">
|
<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">
|
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
|
||||||
{t("auth.signup.submit")}
|
Créer mon compte
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-[14px] text-ink-3">
|
<p className="mt-1.5 text-[14px] text-ink-3">
|
||||||
{t("auth.signup.hasAccount")}{" "}
|
Déjà un compte ?{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="font-medium text-rubis underline-offset-4 hover:underline"
|
className="font-medium text-rubis underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
{t("auth.signup.loginLink")}
|
Se connecter
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-7 flex flex-col gap-2">
|
<div className="mt-7 flex flex-col gap-2">
|
||||||
<SsoButton provider="google" label={t("auth.signup.withGoogle")} />
|
<SsoButton provider="google" label="S'inscrire avec Google" />
|
||||||
<SsoButton provider="microsoft" label={t("auth.signup.withMicrosoft")} />
|
<SsoButton provider="microsoft" label="S'inscrire avec Microsoft" />
|
||||||
<AuthDivider />
|
<AuthDivider />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -132,7 +130,7 @@ function SignupPage() {
|
|||||||
<form.Field name="fullName">
|
<form.Field name="fullName">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field
|
<Field
|
||||||
label={t("auth.onboarding.account.firstNameLabel") + " / " + t("auth.onboarding.account.lastNameLabel")}
|
label="Prénom / Nom"
|
||||||
htmlFor={field.name}
|
htmlFor={field.name}
|
||||||
error={field.state.meta.errors[0]?.message}
|
error={field.state.meta.errors[0]?.message}
|
||||||
>
|
>
|
||||||
@ -155,7 +153,7 @@ function SignupPage() {
|
|||||||
<form.Field name="email">
|
<form.Field name="email">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field
|
<Field
|
||||||
label={t("auth.signup.emailLabel")}
|
label="Email professionnel"
|
||||||
htmlFor={field.name}
|
htmlFor={field.name}
|
||||||
error={field.state.meta.errors[0]?.message}
|
error={field.state.meta.errors[0]?.message}
|
||||||
>
|
>
|
||||||
@ -164,7 +162,7 @@ function SignupPage() {
|
|||||||
name={field.name}
|
name={field.name}
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder={t("auth.signup.emailPlaceholder")}
|
placeholder="vous@entreprise.fr"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
@ -177,9 +175,9 @@ function SignupPage() {
|
|||||||
<form.Field name="password">
|
<form.Field name="password">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<Field
|
<Field
|
||||||
label={t("auth.signup.passwordLabel")}
|
label="Mot de passe"
|
||||||
htmlFor={field.name}
|
htmlFor={field.name}
|
||||||
hint={t("auth.signup.passwordHint")}
|
hint="Au moins 10 caractères, mélangez lettres et chiffres."
|
||||||
error={field.state.meta.errors[0]?.message}
|
error={field.state.meta.errors[0]?.message}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@ -187,7 +185,7 @@ function SignupPage() {
|
|||||||
name={field.name}
|
name={field.name}
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder={t("auth.signup.passwordPlaceholder")}
|
placeholder="Au moins 10 caractères"
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
@ -203,17 +201,22 @@ function SignupPage() {
|
|||||||
loading={signupMutation.isPending}
|
loading={signupMutation.isPending}
|
||||||
className="mt-1 w-full"
|
className="mt-1 w-full"
|
||||||
>
|
>
|
||||||
{t("auth.signup.submit")} <ArrowRight size={16} aria-hidden="true" />
|
Créer mon compte <ArrowRight size={16} aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="mt-1 text-center text-[11.5px] leading-relaxed text-ink-3">
|
<p className="mt-1 text-center text-[11.5px] leading-relaxed text-ink-3">
|
||||||
<Trans
|
En créant un compte, vous acceptez nos{" "}
|
||||||
i18nKey="auth.signup.terms"
|
<a href="/cgv" className="underline underline-offset-4 hover:text-ink">
|
||||||
components={{
|
Conditions générales
|
||||||
1: <a href="/cgv" className="underline underline-offset-4 hover:text-ink" />,
|
</a>{" "}
|
||||||
2: <a href="/confidentialite" className="underline underline-offset-4 hover:text-ink" />,
|
et notre{" "}
|
||||||
}}
|
<a
|
||||||
/>
|
href="/confidentialite"
|
||||||
|
className="underline underline-offset-4 hover:text-ink"
|
||||||
|
>
|
||||||
|
politique de confidentialité
|
||||||
|
</a>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@ -304,9 +304,6 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
i18next:
|
|
||||||
specifier: ^26.2.0
|
|
||||||
version: 26.2.0(typescript@6.0.3)
|
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.475.0
|
specifier: ^0.475.0
|
||||||
version: 0.475.0(react@19.2.5)
|
version: 0.475.0(react@19.2.5)
|
||||||
@ -319,9 +316,6 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.5
|
specifier: ^19.2.5
|
||||||
version: 19.2.5(react@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:
|
recharts:
|
||||||
specifier: ^3.8.1
|
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)
|
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)
|
||||||
@ -5598,9 +5592,6 @@ packages:
|
|||||||
html-escaper@3.0.3:
|
html-escaper@3.0.3:
|
||||||
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
|
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
|
||||||
|
|
||||||
html-parse-stringify@3.0.1:
|
|
||||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
|
||||||
|
|
||||||
html-to-text@9.0.5:
|
html-to-text@9.0.5:
|
||||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@ -5649,14 +5640,6 @@ packages:
|
|||||||
hyphen@1.14.1:
|
hyphen@1.14.1:
|
||||||
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
|
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:
|
ical-generator@10.2.0:
|
||||||
resolution: {integrity: sha512-XR5FsiDWCsz5MwBwMA/sQqR3A9H240xkXIeXOabV7uNAiieP+TA9rleVvlwPLRXMz+CXME8cGuDd7cdnE5At6w==}
|
resolution: {integrity: sha512-XR5FsiDWCsz5MwBwMA/sQqR3A9H240xkXIeXOabV7uNAiieP+TA9rleVvlwPLRXMz+CXME8cGuDd7cdnE5At6w==}
|
||||||
engines: {node: 20 || 22 || >=24}
|
engines: {node: 20 || 22 || >=24}
|
||||||
@ -6930,22 +6913,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.5
|
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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@ -8096,10 +8063,6 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
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:
|
volar-service-css@0.0.70:
|
||||||
resolution: {integrity: sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==}
|
resolution: {integrity: sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -14074,10 +14037,6 @@ snapshots:
|
|||||||
|
|
||||||
html-escaper@3.0.3: {}
|
html-escaper@3.0.3: {}
|
||||||
|
|
||||||
html-parse-stringify@3.0.1:
|
|
||||||
dependencies:
|
|
||||||
void-elements: 3.1.0
|
|
||||||
|
|
||||||
html-to-text@9.0.5:
|
html-to-text@9.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@selderee/plugin-htmlparser2': 0.11.0
|
'@selderee/plugin-htmlparser2': 0.11.0
|
||||||
@ -14136,10 +14095,6 @@ snapshots:
|
|||||||
|
|
||||||
hyphen@1.14.1: {}
|
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):
|
ical-generator@10.2.0(@types/luxon@3.7.1)(@types/node@25.6.0)(dayjs@1.11.20)(luxon@3.7.2):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/luxon': 3.7.1
|
'@types/luxon': 3.7.1
|
||||||
@ -15543,17 +15498,6 @@ snapshots:
|
|||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
scheduler: 0.27.0
|
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@16.13.1: {}
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
@ -16789,8 +16733,6 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
void-elements@3.1.0: {}
|
|
||||||
|
|
||||||
volar-service-css@0.0.70(@volar/language-service@2.4.28):
|
volar-service-css@0.0.70(@volar/language-service@2.4.28):
|
||||||
dependencies:
|
dependencies:
|
||||||
vscode-css-languageservice: 6.3.10
|
vscode-css-languageservice: 6.3.10
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user