rubis/apps/web/src/routes/signup.tsx
ordinarthur 3fc3a7456a
Some checks failed
Build & Deploy Web / build-and-deploy (push) Has been cancelled
Build & Deploy API / build-and-deploy (push) Successful in 2m30s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m31s
feat(web): instrumentation PostHog (analytics + nginx proxy)
Setup PostHog côté SPA — pageviews TanStack Router + 10 events business
(signup, login SSO, upload facture, émission/brouillon facture native,
marquer payée, lancer relance, plan créé, checkout Stripe). PostHogProvider
dans __root.tsx, identify sur auth, proxy nginx /ingest/* → eu.i.posthog.com
pour contourner les adblockers. Token bake via build-arg CI
(POSTHOG_PROJECT_TOKEN, à ajouter côté Gitea Secrets).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:21:59 +02:00

248 lines
9.2 KiB
TypeScript

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 { usePostHog } from "@posthog/react";
import {
registerSchema,
type AuthSession,
type RegisterInput,
} from "@rubis/shared";
import { api, ApiError } from "@/lib/api";
import { authStore } from "@/lib/auth";
import { Button } from "@rubis/ui";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
import { Card } from "@rubis/ui";
import { Eyebrow } from "@rubis/ui";
import { Brand } from "@rubis/ui";
import { Gem } from "@rubis/ui";
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
export const Route = createFileRoute("/signup")({
component: SignupPage,
});
function SignupPage() {
const navigate = useNavigate();
const posthog = usePostHog();
const signupMutation = useMutation({
mutationFn: async (input: RegisterInput) =>
api.post<AuthSession>("/api/v1/auth/signup", input, { anonymous: true }),
onSuccess: (session) => {
posthog.identify(session.user.id, {
email: session.user.email,
name: session.user.fullName,
});
posthog.capture("user_signed_up", { email: session.user.email });
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>
<div className="mt-7 flex flex-col gap-2">
<SsoButton provider="google" label="S'inscrire avec Google" />
<SsoButton provider="microsoft" label="S'inscrire avec Microsoft" />
<AuthDivider />
</div>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="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>
);
}