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:
parent
16120ed3e0
commit
8cec9d2f33
144
apps/web/src/components/settings/AccountForm.tsx
Normal file
144
apps/web/src/components/settings/AccountForm.tsx
Normal 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;
|
||||||
|
}
|
||||||
75
apps/web/src/components/settings/DangerZone.tsx
Normal file
75
apps/web/src/components/settings/DangerZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
apps/web/src/components/settings/OrganizationForm.tsx
Normal file
201
apps/web/src/components/settings/OrganizationForm.tsx
Normal 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'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;
|
||||||
|
}
|
||||||
62
apps/web/src/components/settings/SettingsSection.tsx
Normal file
62
apps/web/src/components/settings/SettingsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/web/src/components/settings/SignatureForm.tsx
Normal file
128
apps/web/src/components/settings/SignatureForm.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -69,6 +69,17 @@ export const onboardingHandlers = [
|
|||||||
return HttpResponse.json({ data: updated });
|
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
|
// PATCH /api/v1/organizations/me — pour l'organisation de l'utilisateur courant
|
||||||
http.patch(`${apiBase}/organizations/me`, async ({ request }) => {
|
http.patch(`${apiBase}/organizations/me`, async ({ request }) => {
|
||||||
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
|
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
|
||||||
|
|||||||
@ -1,36 +1,77 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
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")({
|
export const Route = createFileRoute("/_app/parametres")({
|
||||||
component: ParametresPage,
|
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() {
|
function ParametresPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<header className="mb-4">
|
||||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
|
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
|
||||||
Paramètres
|
Paramètres
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-[14px] text-ink-3">
|
<p className="mt-1.5 text-[14px] text-ink-3">
|
||||||
Compte, entreprise, signature, facturation.
|
Compte, entreprise, signature email — modifiables à tout moment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<EmptyState
|
<div className="flex flex-col gap-10 lg:gap-12">
|
||||||
draft
|
<SettingsSection
|
||||||
icon={<Settings size={36} strokeWidth={1.5} aria-hidden="true" />}
|
eyebrow="Compte"
|
||||||
title="Bientôt ici."
|
title="Vos infos personnelles"
|
||||||
description={
|
description="Le nom et l'email qui vous identifient dans Rubis. L'email sert d'identifiant de connexion et d'expéditeur des relances."
|
||||||
<>
|
>
|
||||||
On va y reposer ce que vous avez rempli à l'onboarding,
|
<AccountForm />
|
||||||
avec en plus la facturation Rubis et la gestion des invitations
|
</SettingsSection>
|
||||||
(V2). Vous pourrez tout modifier à tout moment.
|
|
||||||
</>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user