ordinarthur 332bf0bcda feat(web): /signup + 3-step onboarding flow
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>
2026-05-06 10:22:53 +02:00

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>
);
}