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>
This commit is contained in:
ordinarthur 2026-05-06 10:22:53 +02:00
parent 8d3bab6a89
commit 332bf0bcda
16 changed files with 1157 additions and 42 deletions

View File

@ -0,0 +1,45 @@
import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* Card surface standardisée.
* - `default` : bg-white, border line, ombre douce. Pour les formulaires.
* - `flat` : bg-cream-2, pas d'ombre. Pour les blocs informatifs.
* - `hero` : bg-white, ombre carte (plus marquée). Pour les éléments-héros.
*
* Le radius est 14px (--radius-card) un peu plus rond que les boutons (6px).
* Cohérent avec la landing.
*/
const cardVariants = cva(
cn("rounded-card", "transition-shadow duration-150"),
{
variants: {
variant: {
default: "bg-white border border-line shadow-soft",
flat: "bg-cream-2",
hero: "bg-white border border-line shadow-card",
},
padding: {
none: "p-0",
sm: "p-5",
md: "p-7",
lg: "p-9",
},
},
defaultVariants: {
variant: "default",
padding: "md",
},
},
);
export type CardProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof cardVariants>;
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant, padding, ...props }, ref) => (
<div ref={ref} className={cn(cardVariants({ variant, padding }), className)} {...props} />
),
);
Card.displayName = "Card";

View File

@ -0,0 +1,46 @@
import { forwardRef } from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Chip pastille sélectionnable. Utilisée pour :
* - les filtres de la liste de factures
* - les choix de volume mensuel à l'onboarding
* - les tonalités de relance dans l'éditeur de plan
*
* Forme légèrement allongée (radius 999px = pill), bordure 1px.
* Sélectionnée : fond rubis-glow + texte rubis-deep + pas de plein rubis
* (on garde le rubis pour les CTA, cf. règle d'or de la marque).
*/
export type ChipProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
selected?: boolean;
/** Affiche le ✓ quand sélectionné. Default true. */
withCheck?: boolean;
};
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
({ className, selected = false, withCheck = true, children, type = "button", ...props }, ref) => {
return (
<button
ref={ref}
type={type}
aria-pressed={selected}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5",
"font-sans text-[13px] font-medium",
"transition-[background,border-color,color] duration-150",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
selected
? "border-rubis bg-rubis-glow text-rubis-deep"
: "border-line bg-cream-2 text-ink-2 hover:border-ink-3 hover:text-ink",
className,
)}
{...props}
>
{selected && withCheck && <Check size={13} aria-hidden="true" />}
{children}
</button>
);
},
);
Chip.displayName = "Chip";

View File

@ -0,0 +1,84 @@
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Stepper horizontal pour wizards multi-étapes (onboarding).
* - Étape courante : cercle plein rubis, label rubis
* - Étape complétée : cercle rubis-glow + rubis-deep
* - Étape future : cercle vide bordure line, label muted
* - Lignes entre les cercles : rubis si la précédente est complétée
*
* Pas une UI standard "1—2—3 cliquable". Les étapes ne sont pas navigables :
* c'est un wizard linéaire (cf. /docs/produit.md).
*/
type Step = {
/** Identifiant stable pour `key`. */
id: string;
label: string;
};
type StepperProps = {
steps: ReadonlyArray<Step>;
/** Index 0-based de l'étape courante. */
currentIndex: number;
className?: string;
};
export function Stepper({ steps, currentIndex, className }: StepperProps) {
return (
<ol
className={cn("flex items-center gap-2 sm:gap-3", className)}
aria-label={`Étape ${currentIndex + 1} sur ${steps.length}`}
>
{steps.map((step, idx) => {
const status: "done" | "current" | "todo" =
idx < currentIndex ? "done" : idx === currentIndex ? "current" : "todo";
const isLast = idx === steps.length - 1;
return (
<li key={step.id} className="flex items-center gap-2 sm:gap-3 flex-1">
<div className="flex flex-col items-center gap-2">
<span
aria-current={status === "current" ? "step" : undefined}
className={cn(
"flex size-8 items-center justify-center rounded-full text-[13px] font-semibold",
"transition-[background,border-color,color] duration-200",
status === "current" &&
"bg-rubis text-white shadow-rubis tabular-nums",
status === "done" &&
"bg-rubis-glow text-rubis-deep tabular-nums",
status === "todo" &&
"border border-line bg-white text-ink-3 tabular-nums",
)}
>
{status === "done" ? (
<Check size={15} aria-hidden="true" strokeWidth={2.5} />
) : (
idx + 1
)}
</span>
<span
className={cn(
"hidden sm:block text-[11px] font-semibold uppercase tracking-[0.1em] whitespace-nowrap",
status === "current" && "text-rubis",
status === "done" && "text-ink-2",
status === "todo" && "text-ink-3",
)}
>
{step.label}
</span>
</div>
{!isLast && (
<span
aria-hidden="true"
className={cn(
"h-px flex-1 transition-colors duration-200",
status === "done" ? "bg-rubis" : "bg-line",
)}
/>
)}
</li>
);
})}
</ol>
);
}

View File

@ -0,0 +1,30 @@
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
/**
* Textarea même règles que Input (1px line, focus rubis-glow), avec
* un comportement `auto-resize` optionnel pour les signatures et notes.
*/
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, rows = 4, ...props }, ref) => {
return (
<textarea
ref={ref}
rows={rows}
className={cn(
"block w-full rounded-default border border-line bg-white px-3.5 py-3",
"font-sans text-[15px] text-ink placeholder:text-ink-3",
"transition-[border-color,box-shadow] duration-150 resize-none",
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
"disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3",
"aria-[invalid=true]:border-rubis-deep aria-[invalid=true]:bg-rubis-glow/30",
className,
)}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";

View File

@ -3,12 +3,15 @@
* Persiste dans sessionStorage pour survivre aux reload pendant le dev, * Persiste dans sessionStorage pour survivre aux reload pendant le dev,
* mais reste isolée par onglet (pas d'interférence entre devs). * mais reste isolée par onglet (pas d'interférence entre devs).
*/ */
import type { User } from "@rubis/shared"; import type { Organization, User } from "@rubis/shared";
const STORAGE_KEY = "rubis.mocks.db"; const STORAGE_KEY = "rubis.mocks.db";
type StoredUser = User & { passwordHash: string };
type Db = { type Db = {
users: Array<User & { passwordHash: string }>; users: StoredUser[];
organizations: Organization[];
}; };
const seedDb = (): Db => ({ const seedDb = (): Db => ({
@ -21,10 +24,20 @@ const seedDb = (): Db => ({
signature: "Cordialement,\nArthur — Rubis Démo", signature: "Cordialement,\nArthur — Rubis Démo",
createdAt: new Date("2026-01-01").toISOString(), createdAt: new Date("2026-01-01").toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
// mot de passe : "demo1234"
passwordHash: "demo1234", passwordHash: "demo1234",
}, },
], ],
organizations: [
{
id: "org_demo",
name: "Rubis Démo",
siret: null,
monthlyVolumeBucket: "10-50",
rubisCount: 124,
createdAt: new Date("2026-01-01").toISOString(),
updatedAt: new Date().toISOString(),
},
],
}); });
function load(): Db { function load(): Db {
@ -50,30 +63,91 @@ function save(db: Db): void {
} }
} }
function stripHash(user: StoredUser): User {
const { passwordHash: _ph, ...publicUser } = user;
return publicUser;
}
export const mockDb = { export const mockDb = {
findUserByEmail(email: string): (User & { passwordHash: string }) | undefined { findUserByEmail(email: string): StoredUser | undefined {
const db = load(); return load().users.find((u) => u.email.toLowerCase() === email.toLowerCase());
return db.users.find((u) => u.email.toLowerCase() === email.toLowerCase()); },
findUserById(id: string): StoredUser | undefined {
return load().users.find((u) => u.id === id);
},
findOrgById(id: string): Organization | undefined {
return load().organizations.find((o) => o.id === id);
}, },
createUser(input: { email: string; password: string; fullName: string }): User { createUser(input: { email: string; password: string; fullName: string }): User {
const db = load(); const db = load();
const now = new Date().toISOString();
const orgId = `org_${crypto.randomUUID()}`; const orgId = `org_${crypto.randomUUID()}`;
const user: User & { passwordHash: string } = { const userId = `usr_${crypto.randomUUID()}`;
id: `usr_${crypto.randomUUID()}`,
const org: Organization = {
id: orgId,
name: "",
siret: null,
monthlyVolumeBucket: null,
rubisCount: 0,
createdAt: now,
updatedAt: now,
};
const user: StoredUser = {
id: userId,
email: input.email, email: input.email,
fullName: input.fullName, fullName: input.fullName,
organizationId: orgId, organizationId: orgId,
signature: null, signature: null,
createdAt: new Date().toISOString(), createdAt: now,
updatedAt: new Date().toISOString(), updatedAt: now,
passwordHash: input.password, passwordHash: input.password,
}; };
db.users.push(user); db.users.push(user);
db.organizations.push(org);
save(db); save(db);
// Renvoie sans le hash return stripHash(user);
const { passwordHash: _ph, ...publicUser } = user; },
return publicUser;
updateUser(
id: string,
patch: Partial<Pick<User, "fullName" | "email" | "signature">>,
): User | undefined {
const db = load();
const idx = db.users.findIndex((u) => u.id === id);
if (idx === -1) return undefined;
const existing = db.users[idx]!;
const updated: StoredUser = {
...existing,
...patch,
updatedAt: new Date().toISOString(),
};
db.users[idx] = updated;
save(db);
return stripHash(updated);
},
updateOrg(
id: string,
patch: Partial<Pick<Organization, "name" | "siret" | "monthlyVolumeBucket">>,
): Organization | undefined {
const db = load();
const idx = db.organizations.findIndex((o) => o.id === id);
if (idx === -1) return undefined;
const existing = db.organizations[idx]!;
const updated: Organization = {
...existing,
...patch,
updatedAt: new Date().toISOString(),
};
db.organizations[idx] = updated;
save(db);
return updated;
}, },
reset(): void { reset(): void {

View File

@ -4,7 +4,7 @@ import { mockDb } from "../db";
const apiBase = "*/api/v1"; const apiBase = "*/api/v1";
/** Génère un faux access token signé "à la main" — pas de vraie crypto. */ /** Faux access token signé "à la main" — pas de vraie crypto. */
function fakeToken(userId: string): string { function fakeToken(userId: string): string {
return `mock.${userId}.${Date.now()}`; return `mock.${userId}.${Date.now()}`;
} }
@ -13,6 +13,13 @@ function expiresInMinutes(min: number): string {
return new Date(Date.now() + min * 60_000).toISOString(); return new Date(Date.now() + min * 60_000).toISOString();
} }
/** Extrait l'userId depuis un fake token. Renvoie undefined si invalide. */
export function userIdFromAuthHeader(authHeader: string | null): string | undefined {
if (!authHeader?.startsWith("Bearer mock.")) return undefined;
const userId = authHeader.split(".")[1];
return userId && userId.length > 0 ? userId : undefined;
}
export const authHandlers = [ export const authHandlers = [
// POST /api/v1/auth/login // POST /api/v1/auth/login
http.post(`${apiBase}/auth/login`, async ({ request }) => { http.post(`${apiBase}/auth/login`, async ({ request }) => {
@ -94,7 +101,7 @@ export const authHandlers = [
); );
}), }),
// POST /api/v1/auth/refresh — pour l'instant, pas de refresh token côté mocks. // POST /api/v1/auth/refresh
http.post(`${apiBase}/auth/refresh`, () => { http.post(`${apiBase}/auth/refresh`, () => {
return HttpResponse.json( return HttpResponse.json(
{ errors: [{ code: "no_session", message: "Pas de session active" }] }, { errors: [{ code: "no_session", message: "Pas de session active" }] },
@ -109,29 +116,21 @@ export const authHandlers = [
// GET /api/v1/account/profile // GET /api/v1/account/profile
http.get(`${apiBase}/account/profile`, ({ request }) => { http.get(`${apiBase}/account/profile`, ({ request }) => {
const auth = request.headers.get("authorization"); const userId = userIdFromAuthHeader(request.headers.get("authorization"));
if (!auth?.startsWith("Bearer mock.")) { if (!userId) {
return HttpResponse.json( return HttpResponse.json(
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] }, { errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
{ status: 401 }, { status: 401 },
); );
} }
const userId = auth.split(".")[1]; const user = mockDb.findUserById(userId);
if (!userId) { if (!user) {
return HttpResponse.json(
{ errors: [{ code: "invalid_token", message: "Token invalide" }] },
{ status: 401 },
);
}
// On retrouve l'utilisateur par id
const seed = mockDb.findUserByEmail("demo@rubis.fr");
if (!seed) {
return HttpResponse.json( return HttpResponse.json(
{ errors: [{ code: "not_found", message: "Utilisateur introuvable" }] }, { errors: [{ code: "not_found", message: "Utilisateur introuvable" }] },
{ status: 404 }, { status: 404 },
); );
} }
const { passwordHash: _ph, ...publicUser } = seed; const { passwordHash: _ph, ...publicUser } = user;
return HttpResponse.json({ data: publicUser }); return HttpResponse.json({ data: publicUser });
}), }),
]; ];

View File

@ -1,4 +1,5 @@
import { authHandlers } from "./auth"; import { authHandlers } from "./auth";
import { onboardingHandlers } from "./onboarding";
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */ /** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
export const handlers = [...authHandlers]; export const handlers = [...authHandlers, ...onboardingHandlers];

View File

@ -0,0 +1,87 @@
import { http, HttpResponse } from "msw";
import { z } from "zod";
import { MONTHLY_VOLUME_BUCKETS } from "@rubis/shared";
import { mockDb } from "../db";
import { userIdFromAuthHeader } from "./auth";
const apiBase = "*/api/v1";
const updateProfileSchema = z
.object({
fullName: z.string().min(2).max(120).optional(),
email: z.string().email().optional(),
signature: z.string().max(500).optional(),
})
.strict();
const updateOrgSchema = z
.object({
name: z.string().min(2).max(120).optional(),
siret: z
.string()
.regex(/^\d{14}$/u)
.or(z.literal(""))
.optional()
.transform((v) => (v === "" || v === undefined ? null : v)),
monthlyVolumeBucket: z.enum(MONTHLY_VOLUME_BUCKETS).optional(),
})
.strict();
function unauthenticated() {
return HttpResponse.json(
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
{ status: 401 },
);
}
function notFound() {
return HttpResponse.json(
{ errors: [{ code: "not_found", message: "Ressource introuvable" }] },
{ status: 404 },
);
}
function validationError(issues: z.ZodIssue[]) {
return HttpResponse.json(
{
errors: issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
export const onboardingHandlers = [
// PATCH /api/v1/account/profile — fullName, email, signature
http.patch(`${apiBase}/account/profile`, async ({ request }) => {
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
if (!userId) return unauthenticated();
const json = await request.json();
const parsed = updateProfileSchema.safeParse(json);
if (!parsed.success) return validationError(parsed.error.issues);
const updated = mockDb.updateUser(userId, parsed.data);
if (!updated) return notFound();
return HttpResponse.json({ data: updated });
}),
// 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"));
if (!userId) return unauthenticated();
const user = mockDb.findUserById(userId);
if (!user) return notFound();
const json = await request.json();
const parsed = updateOrgSchema.safeParse(json);
if (!parsed.success) return validationError(parsed.error.issues);
const updated = mockDb.updateOrg(user.organizationId, parsed.data);
if (!updated) return notFound();
return HttpResponse.json({ data: updated });
}),
];

View File

@ -2,14 +2,22 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { authStore } from "@/lib/auth"; import { authStore } from "@/lib/auth";
/** /**
* Route racine "/" redirige selon l'état d'auth. * "/" pas d'UI propre, juste un router.
* Pas de UI propre : c'est juste un router. *
* Décision actuelle :
* - non authentifié /login
* - authentifié /onboarding/compte (placeholder tant que le layout
* `_app` et le dashboard n'existent pas)
*
* Quand le layout `_app` arrivera, on enverra plutôt vers / côté `_app`,
* qui lui-même décidera entre dashboard et onboarding selon
* `organization.onboardingCompletedAt`.
*/ */
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: () => { beforeLoad: () => {
if (authStore.isAuthenticated()) { if (!authStore.isAuthenticated()) {
throw redirect({ to: "/login" }); // Sera /_app/ une fois le layout app prêt throw redirect({ to: "/login" });
} }
throw redirect({ to: "/login" }); throw redirect({ to: "/onboarding/compte" });
}, },
}); });

View File

@ -35,7 +35,9 @@ function LoginPage() {
onSuccess: (session) => { onSuccess: (session) => {
authStore.setSession(session.accessToken, session.user); authStore.setSession(session.accessToken, session.user);
toast.success(`Bonjour ${session.user.fullName.split(" ")[0]}.`); toast.success(`Bonjour ${session.user.fullName.split(" ")[0]}.`);
void navigate({ to: search.redirect ?? "/login" }); // Si on a une URL de redirection (depuis le guard d'auth), on la suit ;
// sinon / qui décide quoi faire selon l'état d'onboarding.
void navigate({ to: search.redirect ?? "/" });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
@ -72,12 +74,13 @@ function LoginPage() {
{/* Colonne gauche message marketing. {/* Colonne gauche message marketing.
Décalée, dense, du caractère. Pas une carte centrée et fade. */} Décalée, dense, du caractère. Pas une carte centrée et fade. */}
<section className="order-2 lg:order-1 max-w-[520px]"> <section className="order-2 lg:order-1 max-w-[520px]">
<Link to="/login" className="inline-block"> <div className="flex flex-col gap-4">
<Brand withSuffix /> <Link to="/login" className="inline-block">
</Link> <Brand withSuffix />
</Link>
<Eyebrow className="mt-12">Bon retour</Eyebrow> <Eyebrow>Bon retour</Eyebrow>
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]"> </div>
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-tight text-ink lg:text-[52px]">
Vos factures vous <em>attendent</em>. Vos factures vous <em>attendent</em>.
<br className="hidden sm:block" /> <br className="hidden sm:block" />
On reprend vous en étiez. On reprend vous en étiez.
@ -111,7 +114,7 @@ function LoginPage() {
<p className="mt-1.5 text-[14px] text-ink-3"> <p className="mt-1.5 text-[14px] text-ink-3">
Pas encore de compte ?{" "} Pas encore de compte ?{" "}
<Link <Link
to="/login" to="/signup"
className="font-medium text-rubis underline-offset-4 hover:underline" className="font-medium text-rubis underline-offset-4 hover:underline"
> >
Créer un compte Créer un compte

View File

@ -0,0 +1,60 @@
import {
Outlet,
Link,
createFileRoute,
redirect,
useRouterState,
} from "@tanstack/react-router";
import { authStore } from "@/lib/auth";
import { Brand } from "@/components/brand/Brand";
import { Stepper } from "@/components/ui/Stepper";
const ONBOARDING_STEPS = [
{ id: "compte", label: "Compte", path: "/onboarding/compte" },
{ id: "entreprise", label: "Entreprise", path: "/onboarding/entreprise" },
{ id: "signature", label: "Signature", path: "/onboarding/signature" },
] as const;
export const Route = createFileRoute("/onboarding")({
beforeLoad: ({ location }) => {
if (!authStore.isAuthenticated()) {
throw redirect({ to: "/login", search: { redirect: location.href } });
}
},
component: OnboardingLayout,
});
function OnboardingLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const currentIndex = Math.max(
0,
ONBOARDING_STEPS.findIndex((s) => pathname.startsWith(s.path)),
);
return (
<main className="min-h-screen bg-cream">
{/* Header minimal — pas de sidebar, pas de nav, juste la marque + le stepper. */}
<header className="border-b border-line bg-cream/85 backdrop-blur-sm">
<div className="mx-auto flex max-w-[920px] items-center justify-between px-6 py-5 lg:px-8">
<Link to="/login">
<Brand withSuffix />
</Link>
<p className="hidden text-[12.5px] text-ink-3 sm:block">
Étape {currentIndex + 1} sur {ONBOARDING_STEPS.length}
</p>
</div>
<div className="mx-auto max-w-[920px] px-6 pb-6 lg:px-8">
<Stepper
steps={ONBOARDING_STEPS.map(({ id, label }) => ({ id, label }))}
currentIndex={currentIndex}
/>
</div>
</header>
<div className="mx-auto w-full max-w-[640px] px-6 py-12 lg:px-8 lg:py-16">
<Outlet />
</div>
</main>
);
}

View File

@ -0,0 +1,131 @@
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>
);
}

View File

@ -0,0 +1,178 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight } from "lucide-react";
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 { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
import { Chip } from "@/components/ui/Chip";
import { Eyebrow } from "@/components/ui/Eyebrow";
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",
};
const entrepriseSchema = z.object({
name: z
.string({ required_error: "Nom de l'entreprise requis" })
.min(2, "Au moins 2 caractères")
.max(120),
// Soit 14 chiffres, soit chaîne vide (= pas renseigné). Pas d'undefined :
// TanStack Form impose un type input strict aligné sur les defaultValues.
siret: z
.string()
.regex(/^\d{14}$/u, "14 chiffres exactement")
.or(z.literal("")),
monthlyVolumeBucket: z.enum(MONTHLY_VOLUME_BUCKETS, {
required_error: "Sélectionnez un volume",
}),
});
type EntrepriseInput = z.infer<typeof entrepriseSchema>;
export const Route = createFileRoute("/onboarding/entreprise")({
component: OnboardingEntreprise,
});
function OnboardingEntreprise() {
const navigate = useNavigate();
const updateOrg = useMutation({
mutationFn: async (input: EntrepriseInput) =>
api.patch<Organization>("/api/v1/organizations/me", input),
onSuccess: () => {
void navigate({ to: "/onboarding/signature" });
},
onError: () => {
toast.error("On n'a pas pu enregistrer. Réessayez dans un instant.");
},
});
const form = useForm({
defaultValues: {
name: "",
siret: "",
monthlyVolumeBucket: "10-50" as EntrepriseInput["monthlyVolumeBucket"],
} satisfies EntrepriseInput,
validators: { onChange: entrepriseSchema },
onSubmit: async ({ value }) => {
await updateOrg.mutateAsync(value);
},
});
return (
<div>
<Eyebrow>Étape 2</Eyebrow>
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
Votre <em>entreprise</em>.
</h1>
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
Le nom apparaîtra dans les emails de relance. Le SIRET est optionnel
mais nécessaire pour les mises en demeure formelles.
</p>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="mt-10 flex flex-col gap-6"
>
<form.Field name="name">
{(field) => (
<Field
label="Nom de l'entreprise"
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="text"
autoFocus
placeholder="Boulangerie Dupont SARL"
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="siret">
{(field) => (
<Field
label="SIRET (optionnel)"
htmlFor={field.name}
hint="14 chiffres. Vous pouvez le compléter plus tard."
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="123 456 789 00012"
value={field.state.value ?? ""}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value.replace(/\s/gu, ""))}
aria-invalid={field.state.meta.errors.length > 0}
/>
</Field>
)}
</form.Field>
<form.Field name="monthlyVolumeBucket">
{(field) => (
<fieldset>
<legend className="font-sans text-[13px] font-semibold text-ink mb-2">
Combien de factures émettez-vous par mois ?
</legend>
<p className="text-[12.5px] text-ink-3 leading-snug mb-3">
Sert juste à proposer le bon plan tarifaire par défaut.
Modifiable n&apos;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="mt-3 flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => navigate({ to: "/onboarding/compte" })}
>
<ArrowLeft size={15} aria-hidden="true" /> Retour
</Button>
<Button type="submit" loading={updateOrg.isPending}>
Continuer <ArrowRight size={16} aria-hidden="true" />
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
/** /onboarding tout court → toujours rediriger vers la première étape. */
export const Route = createFileRoute("/onboarding/")({
beforeLoad: () => {
throw redirect({ to: "/onboarding/compte" });
},
});

View File

@ -0,0 +1,128 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { ArrowLeft, 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 { Textarea } from "@/components/ui/Textarea";
import { Field } from "@/components/ui/Field";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
const signatureSchema = z.object({
signature: z.string().max(500, "500 caractères maximum"),
});
type SignatureInput = z.infer<typeof signatureSchema>;
const DEFAULT_SIGNATURE = (fullName: string): string =>
`Cordialement,\n${fullName}\n— Envoyé via Rubis`;
export const Route = createFileRoute("/onboarding/signature")({
component: OnboardingSignature,
});
function OnboardingSignature() {
const navigate = useNavigate();
const { user } = useAuth();
const updateProfile = useMutation({
mutationFn: async (input: SignatureInput) =>
api.patch<User>("/api/v1/account/profile", input),
onSuccess: (updatedUser) => {
const token = authStore.token;
if (token) authStore.setSession(token, updatedUser);
toast.success("Tout est prêt. Bienvenue chez Rubis.");
// Pas de dashboard encore — / sera le bon endroit une fois le
// layout _app et la route _app/index construits.
void navigate({ to: "/" });
},
onError: () => {
toast.error("On n'a pas pu enregistrer. Réessayez dans un instant.");
},
});
const form = useForm({
defaultValues: {
signature:
user?.signature ?? DEFAULT_SIGNATURE(user?.fullName ?? "Votre nom"),
} satisfies SignatureInput,
validators: { onChange: signatureSchema },
onSubmit: async ({ value }) => {
await updateProfile.mutateAsync(value);
},
});
return (
<div>
<Eyebrow>Étape 3</Eyebrow>
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
Votre <em>signature</em>.
</h1>
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
Apposée à la fin de chaque relance envoyée par Rubis. On a pré-rempli
un standard sobre vous pouvez l&apos;ajuster.
</p>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="mt-10 flex flex-col gap-6"
>
<form.Field name="signature">
{(field) => (
<>
<Field
label="Signature email"
htmlFor={field.name}
hint="Markdown léger non interprété. Pas de HTML."
error={field.state.meta.errors[0]?.message}
>
<Textarea
id={field.name}
name={field.name}
rows={6}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={field.state.meta.errors.length > 0}
/>
</Field>
<Card variant="flat" padding="md" className="mt-2">
<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="mt-3 flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => navigate({ to: "/onboarding/entreprise" })}
>
<ArrowLeft size={15} aria-hidden="true" /> Retour
</Button>
<Button type="submit" loading={updateProfile.isPending}>
Terminer
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,233 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ArrowRight } from "lucide-react";
import {
registerSchema,
type AuthSession,
type RegisterInput,
} from "@rubis/shared";
import { api, ApiError } from "@/lib/api";
import { authStore } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem";
export const Route = createFileRoute("/signup")({
component: SignupPage,
});
function SignupPage() {
const navigate = useNavigate();
const signupMutation = useMutation({
mutationFn: async (input: RegisterInput) =>
api.post<AuthSession>("/api/v1/auth/signup", input, { anonymous: true }),
onSuccess: (session) => {
authStore.setSession(session.accessToken, session.user);
toast.success("Compte créé. On finalise votre installation.");
void navigate({ to: "/onboarding/compte" });
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.status === 422) {
const emailErrs = error.fieldErrors?.["email"];
if (emailErrs?.[0]) {
toast.error(emailErrs[0]);
return;
}
}
toast.error("Inscription impossible. Réessayez dans un instant.");
},
});
const form = useForm({
defaultValues: { fullName: "", email: "", password: "" } satisfies RegisterInput,
validators: { onChange: registerSchema },
onSubmit: async ({ value }) => {
await signupMutation.mutateAsync(value);
},
});
return (
<main className="min-h-screen bg-cream relative overflow-hidden">
{/* Glow rubis discret en haut-droite — signature visuelle. */}
<div
aria-hidden="true"
className="pointer-events-none absolute top-[-180px] right-[-220px] size-[680px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(251,228,234,0.55), transparent 60%)",
}}
/>
<div className="relative z-10 mx-auto grid min-h-screen w-full max-w-[1180px] grid-cols-1 gap-16 px-6 py-12 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:px-8">
{/* Colonne gauche — pitch */}
<section className="order-2 lg:order-1 max-w-[520px]">
<div className="flex flex-col gap-4">
<Link to="/login" className="inline-block">
<Brand withSuffix />
</Link>
<Eyebrow>Bienvenue chez Rubis</Eyebrow>
</div>
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]">
Vos factures relancées <em>toutes seules</em> pendant que vous
travaillez.
</h1>
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
Trois minutes pour configurer, et Rubis prend le relais. Pas de
CRM à apprendre, pas de tableur à entretenir.
</p>
<ul className="mt-10 flex flex-col gap-3 text-[14px] text-ink-2">
<li className="flex items-start gap-2.5">
<Gem size={11} className="mt-1.5" />
<span>
<strong className="font-semibold text-ink">5 h récupérées</strong>{" "}
par semaine en moyenne temps réinjecté dans votre vrai métier.
</span>
</li>
<li className="flex items-start gap-2.5">
<Gem size={11} className="mt-1.5" />
<span>
<strong className="font-semibold text-ink">3 clics</strong> pour
lancer une relance sur une facture neuve.
</span>
</li>
<li className="flex items-start gap-2.5">
<Gem size={11} className="mt-1.5" />
<span>
<strong className="font-semibold text-ink">14 jours offerts</strong>{" "}
au lancement, sans carte bancaire.
</span>
</li>
</ul>
</section>
{/* Colonne droite — formulaire */}
<section className="order-1 lg:order-2">
<Card variant="hero" padding="lg" className="mx-auto w-full max-w-[420px]">
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
Créer mon compte
</h2>
<p className="mt-1.5 text-[14px] text-ink-3">
Déjà inscrit ?{" "}
<Link
to="/login"
className="font-medium text-rubis underline-offset-4 hover:underline"
>
Connexion
</Link>
</p>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="mt-7 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
placeholder="Camille Dubois"
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 pro"
htmlFor={field.name}
hint="Servira d'identifiant et d'expéditeur des relances."
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="email"
autoComplete="email"
placeholder="vous@entreprise.fr"
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="password">
{(field) => (
<Field
label="Mot de passe"
htmlFor={field.name}
hint="8 caractères minimum."
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="password"
autoComplete="new-password"
placeholder="••••••••"
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>
<Button
type="submit"
size="md"
loading={signupMutation.isPending}
className="mt-1 w-full"
>
Créer mon compte <ArrowRight size={16} aria-hidden="true" />
</Button>
<p className="mt-1 text-center text-[11.5px] leading-relaxed text-ink-3">
En créant un compte, vous acceptez nos{" "}
<a href="#" className="underline underline-offset-4 hover:text-ink">
conditions d&apos;utilisation
</a>{" "}
et notre{" "}
<a href="#" className="underline underline-offset-4 hover:text-ink">
politique de confidentialité
</a>
.
</p>
</form>
</Card>
</section>
</div>
</main>
);
}