From 8cec9d2f33fb200906f5eb071a2582537f174b97 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 12:29:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20page=20/parametres=20compl=C3=A8te?= =?UTF-8?q?=20(compte,=20entreprise,=20signature,=20danger)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/settings/AccountForm.tsx | 144 +++++++++++++ .../src/components/settings/DangerZone.tsx | 75 +++++++ .../components/settings/OrganizationForm.tsx | 201 ++++++++++++++++++ .../components/settings/SettingsSection.tsx | 62 ++++++ .../src/components/settings/SignatureForm.tsx | 128 +++++++++++ apps/web/src/mocks/handlers/onboarding.ts | 11 + apps/web/src/routes/_app/parametres.tsx | 81 +++++-- 7 files changed, 682 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/settings/AccountForm.tsx create mode 100644 apps/web/src/components/settings/DangerZone.tsx create mode 100644 apps/web/src/components/settings/OrganizationForm.tsx create mode 100644 apps/web/src/components/settings/SettingsSection.tsx create mode 100644 apps/web/src/components/settings/SignatureForm.tsx diff --git a/apps/web/src/components/settings/AccountForm.tsx b/apps/web/src/components/settings/AccountForm.tsx new file mode 100644 index 0000000..1eea83b --- /dev/null +++ b/apps/web/src/components/settings/AccountForm.tsx @@ -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("/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 ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + className="flex flex-col gap-5" + > + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + +
+ +
+
+ ); +} + +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; +} diff --git a/apps/web/src/components/settings/DangerZone.tsx b/apps/web/src/components/settings/DangerZone.tsx new file mode 100644 index 0000000..39a507c --- /dev/null +++ b/apps/web/src/components/settings/DangerZone.tsx @@ -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("/api/v1/account/logout"), + onSettled: () => { + authStore.clear(); + toast.success("À très vite."); + void navigate({ to: "/login" }); + }, + }); + + return ( +
+
+
+

+ Se déconnecter de cet appareil +

+

+ Connecté en tant que{" "} + {user?.email}. +

+
+ +
+ +
+ +
+
+

+ Supprimer mon compte +

+

+ Vos factures, plans, clients et historique seront effacés. Action + irréversible — RGPD article 17. +

+
+ +
+
+ ); +} diff --git a/apps/web/src/components/settings/OrganizationForm.tsx b/apps/web/src/components/settings/OrganizationForm.tsx new file mode 100644 index 0000000..0cacbbc --- /dev/null +++ b/apps/web/src/components/settings/OrganizationForm.tsx @@ -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("/api/v1/organizations/me"), + }); + + const updateMutation = useMutation({ + mutationFn: (input: FormValues) => + api.patch("/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 ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + className="flex flex-col gap-5" + > + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + + {(field) => ( + + + field.handleChange(e.target.value.replace(/\s/gu, "")) + } + /> + + )} + + + + {(field) => ( +
+ + Volume mensuel de factures + +

+ Sert juste à proposer le bon plan tarifaire par défaut. + Modifiable n'importe quand. +

+
+ {MONTHLY_VOLUME_BUCKETS.map((bucket) => ( + field.handleChange(bucket)} + > + {VOLUME_LABELS[bucket]} + + ))} +
+
+ )} +
+ +
+ +
+
+ ); +} + +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; +} diff --git a/apps/web/src/components/settings/SettingsSection.tsx b/apps/web/src/components/settings/SettingsSection.tsx new file mode 100644 index 0000000..8413a4d --- /dev/null +++ b/apps/web/src/components/settings/SettingsSection.tsx @@ -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 ( +
+
+ {eyebrow} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ + {children} + +
+ ); +} diff --git a/apps/web/src/components/settings/SignatureForm.tsx b/apps/web/src/components/settings/SignatureForm.tsx new file mode 100644 index 0000000..741fb4b --- /dev/null +++ b/apps/web/src/components/settings/SignatureForm.tsx @@ -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("/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 ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + className="flex flex-col gap-5" + > + + {(field) => ( + <> + +