Nouvelles routes : - /signup : inscription (fullName + email + password) → /onboarding/compte - /onboarding : layout avec brand + stepper, auth-guard - /onboarding/compte : étape 1 (nom + email, prefilled depuis la session) - /onboarding/entreprise : étape 2 (nom, SIRET optionnel, chips volume) - /onboarding/signature : étape 3 (signature email + aperçu live) Nouvelles primitives UI : - <Card variant="default|flat|hero" padding="sm|md|lg"> - <Stepper> wizard horizontal (current rubis, done rubis-glow + ✓, todo line) - <Chip selected> : pastille pill, glow + deep quand sélectionnée (le rubis plein reste réservé aux CTA, cf. règle "le rubis est rare") - <Textarea> : mêmes règles a11y/focus que <Input> MSW handlers étendus : - PATCH /api/v1/account/profile (fullName, email, signature) - PATCH /api/v1/organizations/me (name, siret, monthlyVolumeBucket) - mockDb : ajout des organizations, méthodes updateUser/updateOrg Wiring : - /login → "Créer un compte" pointe vers /signup (avant : loop) - /login succès → / (au lieu de /login) - / → /onboarding/compte si auth, /login sinon (placeholder dashboard) - /onboarding/signature succès → / Bundle prod : 113.87 KB gzip core (-2 KB grâce à MSW exclu en prod via import.meta.env.DEV). Chaque route en chunk dédié (1-2 KB gzip). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
import { useForm } from "@tanstack/react-form";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { ArrowRight } 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 { Input } from "@/components/ui/Input";
|
|
import { Field } from "@/components/ui/Field";
|
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
|
|
|
const accountSchema = z.object({
|
|
fullName: z
|
|
.string({ required_error: "Votre prénom et nom" })
|
|
.min(2, "Au moins 2 caractères"),
|
|
email: z
|
|
.string({ required_error: "Votre email est requis" })
|
|
.email("Format d'email invalide"),
|
|
});
|
|
|
|
type AccountInput = z.infer<typeof accountSchema>;
|
|
|
|
export const Route = createFileRoute("/onboarding/compte")({
|
|
component: OnboardingCompte,
|
|
});
|
|
|
|
function OnboardingCompte() {
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
|
|
const updateProfile = useMutation({
|
|
mutationFn: async (input: AccountInput) =>
|
|
api.patch<User>("/api/v1/account/profile", input),
|
|
onSuccess: (updatedUser) => {
|
|
// On garde le token courant, on rafraîchit juste le user.
|
|
const token = authStore.token;
|
|
if (token) authStore.setSession(token, updatedUser);
|
|
void navigate({ to: "/onboarding/entreprise" });
|
|
},
|
|
onError: () => {
|
|
toast.error("On n'a pas pu enregistrer. Réessayez dans un instant.");
|
|
},
|
|
});
|
|
|
|
const form = useForm({
|
|
defaultValues: {
|
|
fullName: user?.fullName ?? "",
|
|
email: user?.email ?? "",
|
|
} satisfies AccountInput,
|
|
validators: { onChange: accountSchema },
|
|
onSubmit: async ({ value }) => {
|
|
await updateProfile.mutateAsync(value);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<Eyebrow>Étape 1</Eyebrow>
|
|
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
|
|
Vous, en <em>deux lignes</em>.
|
|
</h1>
|
|
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
|
|
Ce qui apparaîtra sur les emails de relance que Rubis enverra pour
|
|
vous. Modifiable plus tard depuis vos paramètres.
|
|
</p>
|
|
|
|
<form
|
|
noValidate
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void form.handleSubmit();
|
|
}}
|
|
className="mt-10 flex flex-col gap-5"
|
|
>
|
|
<form.Field name="fullName">
|
|
{(field) => (
|
|
<Field
|
|
label="Prénom et nom"
|
|
htmlFor={field.name}
|
|
error={field.state.meta.errors[0]?.message}
|
|
>
|
|
<Input
|
|
id={field.name}
|
|
name={field.name}
|
|
type="text"
|
|
autoComplete="name"
|
|
autoFocus
|
|
value={field.state.value}
|
|
onBlur={field.handleBlur}
|
|
onChange={(e) => field.handleChange(e.target.value)}
|
|
aria-invalid={field.state.meta.errors.length > 0}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
|
|
<form.Field name="email">
|
|
{(field) => (
|
|
<Field
|
|
label="Email"
|
|
htmlFor={field.name}
|
|
hint="Adresse expéditrice des relances. Évitez les boîtes nominatives ('paul@'), préférez 'compta@'."
|
|
error={field.state.meta.errors[0]?.message}
|
|
>
|
|
<Input
|
|
id={field.name}
|
|
name={field.name}
|
|
type="email"
|
|
autoComplete="email"
|
|
value={field.state.value}
|
|
onBlur={field.handleBlur}
|
|
onChange={(e) => field.handleChange(e.target.value)}
|
|
aria-invalid={field.state.meta.errors.length > 0}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</form.Field>
|
|
|
|
<div className="mt-3 flex justify-end">
|
|
<Button type="submit" loading={updateProfile.isPending}>
|
|
Continuer <ArrowRight size={16} aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|