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:
parent
8d3bab6a89
commit
332bf0bcda
45
apps/web/src/components/ui/Card.tsx
Normal file
45
apps/web/src/components/ui/Card.tsx
Normal 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";
|
||||
46
apps/web/src/components/ui/Chip.tsx
Normal file
46
apps/web/src/components/ui/Chip.tsx
Normal 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";
|
||||
84
apps/web/src/components/ui/Stepper.tsx
Normal file
84
apps/web/src/components/ui/Stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/components/ui/Textarea.tsx
Normal file
30
apps/web/src/components/ui/Textarea.tsx
Normal 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";
|
||||
@ -3,12 +3,15 @@
|
||||
* Persiste dans sessionStorage pour survivre aux reload pendant le dev,
|
||||
* 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";
|
||||
|
||||
type StoredUser = User & { passwordHash: string };
|
||||
|
||||
type Db = {
|
||||
users: Array<User & { passwordHash: string }>;
|
||||
users: StoredUser[];
|
||||
organizations: Organization[];
|
||||
};
|
||||
|
||||
const seedDb = (): Db => ({
|
||||
@ -21,10 +24,20 @@ const seedDb = (): Db => ({
|
||||
signature: "Cordialement,\nArthur — Rubis Démo",
|
||||
createdAt: new Date("2026-01-01").toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
// mot de passe : "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 {
|
||||
@ -50,30 +63,91 @@ function save(db: Db): void {
|
||||
}
|
||||
}
|
||||
|
||||
function stripHash(user: StoredUser): User {
|
||||
const { passwordHash: _ph, ...publicUser } = user;
|
||||
return publicUser;
|
||||
}
|
||||
|
||||
export const mockDb = {
|
||||
findUserByEmail(email: string): (User & { passwordHash: string }) | undefined {
|
||||
const db = load();
|
||||
return db.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
|
||||
findUserByEmail(email: string): StoredUser | undefined {
|
||||
return load().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 {
|
||||
const db = load();
|
||||
const now = new Date().toISOString();
|
||||
const orgId = `org_${crypto.randomUUID()}`;
|
||||
const user: User & { passwordHash: string } = {
|
||||
id: `usr_${crypto.randomUUID()}`,
|
||||
const userId = `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,
|
||||
fullName: input.fullName,
|
||||
organizationId: orgId,
|
||||
signature: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
passwordHash: input.password,
|
||||
};
|
||||
|
||||
db.users.push(user);
|
||||
db.organizations.push(org);
|
||||
save(db);
|
||||
// Renvoie sans le hash
|
||||
const { passwordHash: _ph, ...publicUser } = user;
|
||||
return publicUser;
|
||||
return stripHash(user);
|
||||
},
|
||||
|
||||
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 {
|
||||
|
||||
@ -4,7 +4,7 @@ import { mockDb } from "../db";
|
||||
|
||||
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 {
|
||||
return `mock.${userId}.${Date.now()}`;
|
||||
}
|
||||
@ -13,6 +13,13 @@ function expiresInMinutes(min: number): string {
|
||||
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 = [
|
||||
// POST /api/v1/auth/login
|
||||
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`, () => {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "no_session", message: "Pas de session active" }] },
|
||||
@ -109,29 +116,21 @@ export const authHandlers = [
|
||||
|
||||
// GET /api/v1/account/profile
|
||||
http.get(`${apiBase}/account/profile`, ({ request }) => {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (!auth?.startsWith("Bearer mock.")) {
|
||||
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
|
||||
if (!userId) {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const userId = auth.split(".")[1];
|
||||
if (!userId) {
|
||||
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) {
|
||||
const user = mockDb.findUserById(userId);
|
||||
if (!user) {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "not_found", message: "Utilisateur introuvable" }] },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const { passwordHash: _ph, ...publicUser } = seed;
|
||||
const { passwordHash: _ph, ...publicUser } = user;
|
||||
return HttpResponse.json({ data: publicUser });
|
||||
}),
|
||||
];
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { authHandlers } from "./auth";
|
||||
import { onboardingHandlers } from "./onboarding";
|
||||
|
||||
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
||||
export const handlers = [...authHandlers];
|
||||
export const handlers = [...authHandlers, ...onboardingHandlers];
|
||||
|
||||
87
apps/web/src/mocks/handlers/onboarding.ts
Normal file
87
apps/web/src/mocks/handlers/onboarding.ts
Normal 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 });
|
||||
}),
|
||||
];
|
||||
@ -2,14 +2,22 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { authStore } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Route racine "/" — redirige selon l'état d'auth.
|
||||
* Pas de UI propre : c'est juste un router.
|
||||
* "/" — pas d'UI propre, 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("/")({
|
||||
beforeLoad: () => {
|
||||
if (authStore.isAuthenticated()) {
|
||||
throw redirect({ to: "/login" }); // Sera /_app/ une fois le layout app prêt
|
||||
}
|
||||
if (!authStore.isAuthenticated()) {
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
throw redirect({ to: "/onboarding/compte" });
|
||||
},
|
||||
});
|
||||
|
||||
@ -35,7 +35,9 @@ function LoginPage() {
|
||||
onSuccess: (session) => {
|
||||
authStore.setSession(session.accessToken, session.user);
|
||||
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) => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
@ -72,12 +74,13 @@ function LoginPage() {
|
||||
{/* Colonne gauche — message marketing.
|
||||
Décalée, dense, du caractère. Pas une carte centrée et fade. */}
|
||||
<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 className="mt-12">Bon retour</Eyebrow>
|
||||
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]">
|
||||
<Eyebrow>Bon retour</Eyebrow>
|
||||
</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>.
|
||||
<br className="hidden sm:block" />
|
||||
On reprend où vous en étiez.
|
||||
@ -111,7 +114,7 @@ function LoginPage() {
|
||||
<p className="mt-1.5 text-[14px] text-ink-3">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
to="/signup"
|
||||
className="font-medium text-rubis underline-offset-4 hover:underline"
|
||||
>
|
||||
Créer un compte
|
||||
|
||||
60
apps/web/src/routes/onboarding.tsx
Normal file
60
apps/web/src/routes/onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
apps/web/src/routes/onboarding/compte.tsx
Normal file
131
apps/web/src/routes/onboarding/compte.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
apps/web/src/routes/onboarding/entreprise.tsx
Normal file
178
apps/web/src/routes/onboarding/entreprise.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
8
apps/web/src/routes/onboarding/index.tsx
Normal file
8
apps/web/src/routes/onboarding/index.tsx
Normal 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" });
|
||||
},
|
||||
});
|
||||
128
apps/web/src/routes/onboarding/signature.tsx
Normal file
128
apps/web/src/routes/onboarding/signature.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
233
apps/web/src/routes/signup.tsx
Normal file
233
apps/web/src/routes/signup.tsx
Normal 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'utilisation
|
||||
</a>{" "}
|
||||
et notre{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-ink">
|
||||
politique de confidentialité
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user