feat(web): page /parametres complète (compte, entreprise, signature, danger)

Remplace le placeholder par 4 sections fonctionnelles, chacune avec son
form indépendant et son Save (blast radius clair : modifier sa signature
ne sauvegarde pas l'org).

Layout : sections verticales avec gap large, pas de tabs ni sidebar
interne en V1 (mono-utilisateur, peu de surface). Pattern type Linear /
Stripe : eyebrow + titre + description à gauche (280px), Card form à
droite (1fr). Empilé sur mobile.

Sections :

1. Compte — AccountForm : fullName + email. Synchronise authStore
   après save → topbar greeting / sidebar avatar se mettent à jour
   live. Save désactivé si form.state.isDirty=false.

2. Entreprise — OrganizationForm : nom + SIRET (14 chiffres) + chips
   volume mensuel (réutilise le pattern de l'onboarding step 2).
   Fetch GET /organizations/me, PATCH au save, setQueryData pour
   éviter un refetch.

3. Signature — SignatureForm : Textarea + aperçu live dans Card flat
   avec eyebrow + Sparkles (cohérent onboarding step 3). PATCH
   /account/profile avec field signature.

4. Zone danger — DangerZone, variant 'danger' sur SettingsSection
   (border rubis-deep/30 dashed + bg rubis-glow/20 — sobre, pas
   alarmiste). Logout fonctionnel (duplique UserMenu, c'est OK et
   attendu dans les paramètres). Suppression compte disabled
   (bientôt) avec mention 'RGPD article 17'.

Composants nouveaux :
- SettingsSection : pattern visuel commun, prop tone='default'|'danger'
- AccountForm, OrganizationForm, SignatureForm, DangerZone

MSW : ajout GET /api/v1/organizations/me (on n'avait que le PATCH).

Bundle prod : 116.21 KB gzip core (-1.76 KB grâce au tree-shaking
mutualisé des deps form).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 12:29:31 +02:00
parent 16120ed3e0
commit 8cec9d2f33
7 changed files with 682 additions and 20 deletions

View File

@ -0,0 +1,144 @@
import { useEffect } from "react";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { z } from "zod";
import type { User } from "@rubis/shared";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
/**
* Form section "Compte" fullName + email.
* La signature email est dans son propre form (SignatureForm) pour clarté.
*/
type FormValues = {
fullName: string;
email: string;
};
const validators = {
fullName: z.string().min(2, "Au moins 2 caractères").max(120),
email: z.string().min(1, "Email requis").email("Format d'email invalide"),
};
export function AccountForm() {
const { user } = useAuth();
const updateMutation = useMutation({
mutationFn: (input: FormValues) =>
api.patch<User>("/api/v1/account/profile", input),
onSuccess: (updated) => {
// Synchronise authStore — sinon le topbar / sidebar continuent d'afficher
// l'ancien fullName.
const token = authStore.token;
if (token) authStore.setSession(token, updated);
toast.success("Compte mis à jour.");
},
onError: () => {
toast.error("Sauvegarde impossible. Réessayez.");
},
});
const initialValues: FormValues = {
fullName: user?.fullName ?? "",
email: user?.email ?? "",
};
const form = useForm({
defaultValues: initialValues,
onSubmit: async ({ value }) => {
for (const [_, schema, val] of [
["fullName", validators.fullName, value.fullName],
["email", validators.email, value.email],
] as const) {
const r = schema.safeParse(val);
if (!r.success) {
toast.error(r.error.issues[0]?.message ?? "Champ invalide");
return;
}
}
await updateMutation.mutateAsync(value);
},
});
// Re-sync quand l'user change (rare en pratique mais cohérent)
useEffect(() => {
form.reset(initialValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex flex-col gap-5"
>
<form.Field name="fullName" validators={{ onChange: validators.fullName }}>
{(field) => (
<Field
label="Prénom et nom"
htmlFor={field.name}
error={firstError(field.state.meta.errors)}
>
<Input
id={field.name}
autoComplete="name"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<form.Field name="email" validators={{ onChange: validators.email }}>
{(field) => (
<Field
label="Email"
htmlFor={field.name}
hint="Sert d'identifiant de connexion et d'expéditeur des relances."
error={firstError(field.state.meta.errors)}
>
<Input
id={field.name}
type="email"
autoComplete="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!form.state.isDirty}
>
{form.state.isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
</form>
);
}
function firstError(errors: unknown[]): string | undefined {
const first = errors[0];
if (!first) return undefined;
if (typeof first === "string") return first;
if (typeof first === "object" && first !== null && "message" in first) {
const msg = (first as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return undefined;
}

View File

@ -0,0 +1,75 @@
import { useNavigate } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import { LogOut, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
/**
* Zone "danger" : déconnexion + suppression de compte (RGPD V2).
* Le bouton de logout duplique celui du UserMenu c'est volontaire,
* c'est un point de sortie attendu dans les paramètres.
*/
export function DangerZone() {
const { user } = useAuth();
const navigate = useNavigate();
const logoutMutation = useMutation({
mutationFn: () => api.post<void>("/api/v1/account/logout"),
onSettled: () => {
authStore.clear();
toast.success("À très vite.");
void navigate({ to: "/login" });
},
});
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[14px] font-semibold text-ink">
Se déconnecter de cet appareil
</p>
<p className="mt-0.5 text-[12.5px] text-ink-3">
Connecté en tant que{" "}
<strong className="text-ink-2">{user?.email}</strong>.
</p>
</div>
<Button
variant="secondary"
size="sm"
loading={logoutMutation.isPending}
onClick={() => logoutMutation.mutate()}
>
<LogOut size={14} aria-hidden="true" /> Se déconnecter
</Button>
</div>
<div className="border-t border-rubis-deep/20" />
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-md">
<p className="text-[14px] font-semibold text-ink">
Supprimer mon compte
</p>
<p className="mt-0.5 text-[12.5px] text-ink-3 leading-relaxed">
Vos factures, plans, clients et historique seront effacés. Action
irréversible RGPD article 17.
</p>
</div>
<Button
variant="danger"
size="sm"
disabled
className="shrink-0"
>
<Trash2 size={14} aria-hidden="true" /> Supprimer
<span className="ml-1 text-[11px] italic opacity-80">(bientôt)</span>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,201 @@
import { useEffect } from "react";
import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { z } from "zod";
import {
MONTHLY_VOLUME_BUCKETS,
type Organization,
} from "@rubis/shared";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import { Chip } from "@/components/ui/Chip";
/**
* Form section "Entreprise" nom, SIRET, volume mensuel.
* Réutilise le pattern des chips volume de l'onboarding step 2.
*/
const VOLUME_LABELS: Record<(typeof MONTHLY_VOLUME_BUCKETS)[number], string> = {
"moins-10": "Moins de 10",
"10-50": "10 à 50",
"50-100": "50 à 100",
"100-200": "100 à 200",
"plus-200": "Plus de 200",
};
type VolumeBucket = (typeof MONTHLY_VOLUME_BUCKETS)[number];
type FormValues = {
name: string;
siret: string;
monthlyVolumeBucket: VolumeBucket;
};
const validators = {
name: z.string().min(2, "Au moins 2 caractères").max(120),
siret: z
.string()
.refine(
(v) => v === "" || /^\d{14}$/u.test(v),
"14 chiffres exactement (ou laissez vide)",
),
};
const ORG_QUERY_KEY = ["organization", "me"] as const;
export function OrganizationForm() {
const queryClient = useQueryClient();
const { data: org } = useQuery({
queryKey: ORG_QUERY_KEY,
queryFn: () => api.get<Organization>("/api/v1/organizations/me"),
});
const updateMutation = useMutation({
mutationFn: (input: FormValues) =>
api.patch<Organization>("/api/v1/organizations/me", {
name: input.name.trim(),
siret: input.siret.trim() === "" ? null : input.siret.trim(),
monthlyVolumeBucket: input.monthlyVolumeBucket,
}),
onSuccess: (updated) => {
queryClient.setQueryData(ORG_QUERY_KEY, updated);
toast.success("Entreprise mise à jour.");
},
onError: () => {
toast.error("Sauvegarde impossible. Réessayez.");
},
});
const initialValues: FormValues = {
name: org?.name ?? "",
siret: org?.siret ?? "",
monthlyVolumeBucket: (org?.monthlyVolumeBucket ?? "10-50") as VolumeBucket,
};
const form = useForm({
defaultValues: initialValues,
onSubmit: async ({ value }) => {
const nameCheck = validators.name.safeParse(value.name);
if (!nameCheck.success) {
toast.error(nameCheck.error.issues[0]?.message ?? "Nom invalide");
return;
}
const siretCheck = validators.siret.safeParse(value.siret);
if (!siretCheck.success) {
toast.error(siretCheck.error.issues[0]?.message ?? "SIRET invalide");
return;
}
await updateMutation.mutateAsync(value);
},
});
// Sync quand le serveur renvoie les données (premier render = org undefined)
useEffect(() => {
if (org) form.reset(initialValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [org?.id, org?.updatedAt]);
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex flex-col gap-5"
>
<form.Field name="name" validators={{ onChange: validators.name }}>
{(field) => (
<Field
label="Nom de l'entreprise"
htmlFor={field.name}
hint="Apparaît dans les emails de relance envoyés à vos clients."
error={firstError(field.state.meta.errors)}
>
<Input
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<form.Field name="siret" validators={{ onChange: validators.siret }}>
{(field) => (
<Field
label="SIRET"
htmlFor={field.name}
hint="14 chiffres. Nécessaire pour les mises en demeure formelles."
error={firstError(field.state.meta.errors)}
>
<Input
id={field.name}
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="123 456 789 00012"
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value.replace(/\s/gu, ""))
}
/>
</Field>
)}
</form.Field>
<form.Field name="monthlyVolumeBucket">
{(field) => (
<fieldset>
<legend className="font-sans text-[13px] font-semibold text-ink mb-2">
Volume mensuel de factures
</legend>
<p className="mb-3 text-[12.5px] text-ink-3 leading-snug">
Sert juste à proposer le bon plan tarifaire par défaut.
Modifiable n&apos;importe quand.
</p>
<div className="flex flex-wrap gap-2">
{MONTHLY_VOLUME_BUCKETS.map((bucket) => (
<Chip
key={bucket}
selected={field.state.value === bucket}
onClick={() => field.handleChange(bucket)}
>
{VOLUME_LABELS[bucket]}
</Chip>
))}
</div>
</fieldset>
)}
</form.Field>
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!form.state.isDirty}
>
{form.state.isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
</form>
);
}
function firstError(errors: unknown[]): string | undefined {
const first = errors[0];
if (!first) return undefined;
if (typeof first === "string") return first;
if (typeof first === "object" && first !== null && "message" in first) {
const msg = (first as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return undefined;
}

View File

@ -0,0 +1,62 @@
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { cn } from "@/lib/utils";
/**
* SettingsSection pattern visuel commun aux sections de /parametres.
*
* Layout 2-col asymétrique (cohérent avec les patterns de paramètres
* Linear/Stripe-style) :
* - Gauche : eyebrow + titre + description (max 320px)
* - Droite : Card avec le contenu (form, danger zone, etc.)
*
* Sur mobile : empilé.
*/
type SettingsSectionProps = {
eyebrow: string;
title: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
/** Variant pour la zone danger : Card en bordure rubis-deep dashed. */
tone?: "default" | "danger";
className?: string;
};
export function SettingsSection({
eyebrow,
title,
description,
children,
tone = "default",
className,
}: SettingsSectionProps) {
return (
<section
className={cn(
"grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr] lg:gap-10",
className,
)}
>
<div>
<Eyebrow tone={tone === "danger" ? "ink" : "rubis"}>{eyebrow}</Eyebrow>
<h2 className="mt-2 font-display text-[20px] font-semibold tracking-[-0.018em] text-ink">
{title}
</h2>
{description && (
<p className="mt-2 text-[13px] leading-relaxed text-ink-3">
{description}
</p>
)}
</div>
<Card
padding="md"
className={cn(
tone === "danger" &&
"border-rubis-deep/30 border-dashed bg-rubis-glow/20",
)}
>
{children}
</Card>
</section>
);
}

View File

@ -0,0 +1,128 @@
import { useEffect } from "react";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
import type { User } from "@rubis/shared";
import { api } from "@/lib/api";
import { authStore, useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Field } from "@/components/ui/Field";
import { Textarea } from "@/components/ui/Textarea";
/**
* Form section "Signature" apposée à la fin de chaque relance.
* Réutilise le pattern de l'onboarding step 3 (avec aperçu live).
*/
type FormValues = { signature: string };
const validator = z
.string()
.max(500, "500 caractères maximum");
const DEFAULT_SIGNATURE = (fullName: string): string =>
`Cordialement,\n${fullName}\n— Envoyé via Rubis`;
export function SignatureForm() {
const { user } = useAuth();
const updateMutation = useMutation({
mutationFn: (input: FormValues) =>
api.patch<User>("/api/v1/account/profile", { signature: input.signature }),
onSuccess: (updated) => {
const token = authStore.token;
if (token) authStore.setSession(token, updated);
toast.success("Signature mise à jour.");
},
onError: () => {
toast.error("Sauvegarde impossible. Réessayez.");
},
});
const initialSignature =
user?.signature ?? DEFAULT_SIGNATURE(user?.fullName ?? "Votre nom");
const form = useForm({
defaultValues: { signature: initialSignature } satisfies FormValues,
onSubmit: async ({ value }) => {
const r = validator.safeParse(value.signature);
if (!r.success) {
toast.error(r.error.issues[0]?.message ?? "Signature invalide");
return;
}
await updateMutation.mutateAsync(value);
},
});
useEffect(() => {
form.reset({ signature: initialSignature });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex flex-col gap-5"
>
<form.Field name="signature" validators={{ onChange: validator }}>
{(field) => (
<>
<Field
label="Signature email"
htmlFor={field.name}
hint="Markdown léger non interprété. Pas de HTML."
error={firstError(field.state.meta.errors)}
>
<Textarea
id={field.name}
rows={5}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
<Card variant="flat" padding="md">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-ink-3">
<Sparkles size={12} aria-hidden="true" /> Aperçu dans un email
</p>
<pre className="mt-3 whitespace-pre-wrap font-sans text-[14px] leading-relaxed text-ink-2">
{field.state.value || "(signature vide)"}
</pre>
</Card>
</>
)}
</form.Field>
<div className="flex justify-end">
<Button
type="submit"
size="sm"
loading={updateMutation.isPending}
disabled={!form.state.isDirty}
>
{form.state.isDirty ? "Enregistrer" : "Aucune modification"}
</Button>
</div>
</form>
);
}
function firstError(errors: unknown[]): string | undefined {
const first = errors[0];
if (!first) return undefined;
if (typeof first === "string") return first;
if (typeof first === "object" && first !== null && "message" in first) {
const msg = (first as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return undefined;
}

View File

@ -69,6 +69,17 @@ export const onboardingHandlers = [
return HttpResponse.json({ data: updated });
}),
// GET /api/v1/organizations/me — l'organisation de l'utilisateur courant
http.get(`${apiBase}/organizations/me`, ({ request }) => {
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
if (!userId) return unauthenticated();
const user = mockDb.findUserById(userId);
if (!user) return notFound();
const org = mockDb.findOrgById(user.organizationId);
if (!org) return notFound();
return HttpResponse.json({ data: org });
}),
// PATCH /api/v1/organizations/me — pour l'organisation de l'utilisateur courant
http.patch(`${apiBase}/organizations/me`, async ({ request }) => {
const userId = userIdFromAuthHeader(request.headers.get("authorization"));

View File

@ -1,36 +1,77 @@
import { createFileRoute } from "@tanstack/react-router";
import { Settings } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { SettingsSection } from "@/components/settings/SettingsSection";
import { AccountForm } from "@/components/settings/AccountForm";
import { OrganizationForm } from "@/components/settings/OrganizationForm";
import { SignatureForm } from "@/components/settings/SignatureForm";
import { DangerZone } from "@/components/settings/DangerZone";
export const Route = createFileRoute("/_app/parametres")({
component: ParametresPage,
});
/**
* /parametres l'utilisateur édite ce qu'il avait rempli à l'onboarding,
* sa signature, et accède à la zone danger (logout + suppression compte).
*
* Layout choisi : sections verticales (pas tabs ni sidebar internes), pour
* un flow de scroll naturel V1 mono-utilisateur, peu de surface, ça se
* lit mieux d'un seul jet. La sidebar/tabs viendront quand on aura
* "Facturation", "Équipe", "Notifications", "API tokens" (V2).
*
* Chaque section a son propre form avec son propre Save button clarté
* du blast radius (modifier sa signature ne sauvegarde pas l'org, etc.).
*/
function ParametresPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
<div className="flex flex-col gap-2">
<header className="mb-4">
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Paramètres
</h1>
<p className="mt-1 text-[14px] text-ink-3">
Compte, entreprise, signature, facturation.
<p className="mt-1.5 text-[14px] text-ink-3">
Compte, entreprise, signature email modifiables à tout moment.
</p>
</div>
</header>
<EmptyState
draft
icon={<Settings size={36} strokeWidth={1.5} aria-hidden="true" />}
title="Bientôt ici."
description={
<>
On va y reposer ce que vous avez rempli à l&apos;onboarding,
avec en plus la facturation Rubis et la gestion des invitations
(V2). Vous pourrez tout modifier à tout moment.
</>
}
/>
<div className="flex flex-col gap-10 lg:gap-12">
<SettingsSection
eyebrow="Compte"
title="Vos infos personnelles"
description="Le nom et l'email qui vous identifient dans Rubis. L'email sert d'identifiant de connexion et d'expéditeur des relances."
>
<AccountForm />
</SettingsSection>
<SettingsSection
eyebrow="Entreprise"
title="Votre structure"
description="Le nom apparaît dans tous les emails de relance. Le SIRET est nécessaire pour les mises en demeure formelles."
>
<OrganizationForm />
</SettingsSection>
<SettingsSection
eyebrow="Signature"
title={
<>
Comment vous <em className="text-rubis">signez</em>
</>
}
description="Apposée à la fin de chaque relance que Rubis envoie pour vous. Cinq lignes max, ton sobre — c'est l'image qu'on laisse à votre client."
>
<SignatureForm />
</SettingsSection>
<SettingsSection
eyebrow="Zone danger"
title="Déconnexion et compte"
description="Sortir de Rubis sur cet appareil ou supprimer définitivement votre compte (RGPD)."
tone="danger"
>
<DangerZone />
</SettingsSection>
</div>
</div>
);
}