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>
248 lines
9.2 KiB
TypeScript
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'utilisation
|
|
</a>{" "}
|
|
et notre{" "}
|
|
<a href="#" className="underline underline-offset-4 hover:text-ink">
|
|
politique de confidentialité
|
|
</a>
|
|
.
|
|
</p>
|
|
</form>
|
|
</Card>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|