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 });
|
||||
}),
|
||||
|
||||
// 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"));
|
||||
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user