feat(billing): redesign page abonnement — layout asymétrique + identité Rubis
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 19s

Mensuel par défaut dans le toggle (au lieu de yearly).

Layout asymétrique 1fr/1.3fr/1fr avec Pro en hero card centrale, plus
large et plus dense visuellement que Free et Business — sort du pattern
"3 cards égales" générique des landing pages SaaS.

Identité Rubis :
  - Gem 280px en filigrane sur la Pro card (rubis-glow, opacity 70%) —
    le motif signature de la marque, pas un blob de gradient
  - Bullets ◆ losanges rubis tournés (5×5px) au lieu des check-circles
    Lucide génériques — cohérent avec PlanCard, Timeline, etc.
  - Pastille "Le plus pris" avec mini-gem inline sur Pro
  - Border rubis + shadow-card sur Pro, line + sobre pour les voisines
  - Free : bg cream-2 (ton "découverte"), Business : bg blanc + accents
    rubis-deep
  - Strip "plan courant" : remplace le gros bloc card → ligne flex avec
    gem + texte + mini progress bar 32px + bouton ghost. Discret.

Voice direct dans les bénéfices :
  - Header narratif "Trois mois pour voir. Puis vous décidez."
  - "Tester sans poser sa CB" (Free)
  - "Pour les TPE qui n'ont plus jamais à y penser" (Pro titre)
  - "Moins cher qu'une heure de votre temps mensuel" (Pro footer)
  - Annulation 1-clic mentionné en sous-titre du toggle

Prix Pro XL 44px (vs 28px pour les voisines) — hiérarchie visuelle qui
guide l'œil vers l'option qu'on veut pousser.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 16:34:46 +02:00
parent 1952265217
commit fd24ef42a6

View File

@ -1,20 +1,13 @@
import { useEffect, useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import {
ArrowLeft,
ArrowRight,
Check,
CreditCard,
Sparkles,
Users,
Zap,
} from "lucide-react";
import { ArrowLeft, ArrowRight, CreditCard } from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
import { useOpenPortal, useStartCheckout, useSubscription, type BillingCycle, type PlanKey } from "@/lib/billing";
import { useOpenPortal, useStartCheckout, useSubscription, type BillingCycle } from "@/lib/billing";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/format";
import { Gem } from "@/components/brand/Gem";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@ -30,22 +23,13 @@ export const Route = createFileRoute("/_app/parametres_/abonnement")({
component: AbonnementPage,
});
/**
* /parametres/abonnement gestion du plan et de la facturation Rubis.
*
* Sections :
* - Plan actuel + caps + état grace period
* - Comparaison Free / Pro / Business avec toggle mensuel/annuel
* - CTA upgrade (Stripe Checkout) ou portail (Customer Portal)
*/
function AbonnementPage() {
const search = Route.useSearch();
const { data: sub, isPending } = useSubscription();
const checkout = useStartCheckout();
const portal = useOpenPortal();
const [cycle, setCycle] = useState<BillingCycle>("yearly");
const [cycle, setCycle] = useState<BillingCycle>("monthly");
// Toast post-redirect Stripe
useEffect(() => {
if (search.checkout === "success") {
toast.success("Bienvenue sur le nouveau plan ! Activation en cours…");
@ -75,8 +59,10 @@ function AbonnementPage() {
});
};
const currentPlan = sub?.plan;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-7">
<Link
to="/parametres"
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
@ -84,71 +70,69 @@ function AbonnementPage() {
<ArrowLeft size={13} aria-hidden="true" /> Paramètres
</Link>
<header>
{/* Header — direct, pas trop de baratin */}
<header className="max-w-2xl">
<Eyebrow>Abonnement</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Choisir <em className="text-rubis">son plan</em>
<h1 className="mt-2 font-display text-[30px] font-bold tracking-[-0.024em] text-ink lg:text-[36px] leading-[1.05]">
Trois mois pour voir.
<br />
Puis vous <em className="text-rubis">décidez</em>.
</h1>
<p className="mt-1.5 text-[14px] text-ink-3 max-w-2xl leading-relaxed">
Trois mois offerts au démarrage. Au-delà, passez Pro pour
continuer à relancer plus de 5 factures à la fois.
<p className="mt-3 text-[14.5px] text-ink-2 leading-relaxed">
Pas de carte demandée pour démarrer. Au-delà de 5 factures actives,
vous passez Pro pour continuer quand vous êtes prêt, pas avant.
</p>
</header>
{/* Plan courant */}
{/* État courant — slim, info-dense, pas un gros bloc */}
{!isPending && sub && (
<CurrentPlanCard
<CurrentPlanStrip
state={sub}
onOpenPortal={onOpenPortal}
isOpeningPortal={portal.isPending}
/>
)}
{/* Toggle mensuel / annuel */}
<div className="self-start">
{/* Toggle cycle */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<CycleToggle value={cycle} onChange={setCycle} />
<p className="text-[11.5px] italic text-ink-3">
Annulation 1-clic · pas d'engagement
</p>
</div>
{/* Cards plans */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:gap-5">
<PlanCard
plan="free"
currentPlan={sub?.plan}
{/* Layout asymétrique : Pro en hero centré, Free et Business plus modestes */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1.3fr_1fr] lg:gap-5 lg:items-stretch">
<PlanFree current={currentPlan === "free"} />
<PlanProHero
current={currentPlan === "pro"}
cycle={cycle}
ctaDisabled
ctaLabel={sub?.plan === "free" ? "Plan actuel" : "Plan gratuit"}
/>
<PlanCard
plan="pro"
currentPlan={sub?.plan}
cycle={cycle}
highlight
loading={checkout.isPending && checkout.variables?.plan === "pro"}
onUpgrade={() => onUpgrade("pro")}
/>
<PlanCard
plan="business"
currentPlan={sub?.plan}
<PlanBusiness
current={currentPlan === "business"}
cycle={cycle}
loading={checkout.isPending && checkout.variables?.plan === "business"}
onUpgrade={() => onUpgrade("business")}
/>
</section>
{/* Footer rassurant */}
<p className="text-[12px] text-ink-3 italic max-w-2xl leading-relaxed">
Paiement sécurisé via Stripe (CB, SEPA). TVA selon votre pays.
Annulation possible à tout moment depuis le portail client. Aucun
engagement.
Paiement sécurisé via Stripe CB ou SEPA, TVA selon votre pays.
Vous gardez la main sur votre abonnement à tout moment depuis le
portail client.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Card plan courant
// Strip "Plan courant" — discret mais informatif (vs gros bloc IA-template)
// ---------------------------------------------------------------------------
function CurrentPlanCard({
function CurrentPlanStrip({
state,
onOpenPortal,
isOpeningPortal,
@ -158,72 +142,71 @@ function CurrentPlanCard({
isOpeningPortal: boolean;
}) {
const { plan, activeInvoicesCount, caps, inGracePeriod, gracePeriodEndsAt } = state;
const isLimited = plan === "free" && caps.activeInvoicesLimit !== null;
const limit = caps.activeInvoicesLimit;
const isLimited = plan === "free" && limit !== null;
const limitReached =
isLimited && limit !== null && !inGracePeriod && activeInvoicesCount >= limit;
return (
<Card padding="md" className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<Eyebrow tone="ink">Plan actuel</Eyebrow>
<p className="mt-2 font-display text-[22px] font-bold text-ink">
Rubis {planLabel(plan)}
<div
className={cn(
"flex items-center gap-4 rounded-card border bg-white px-5 py-4",
"border-line",
)}
>
<Gem size={22} className="shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-display text-[15px] font-bold text-ink leading-tight">
{plan === "free" ? "Plan Rubis" : "Rubis"}
{plan !== "free" && (
<span className="ml-1 text-rubis">{plan === "pro" ? " Pro" : " Business"}</span>
)}
{state.subscriptionStatus && state.subscriptionStatus !== "active" && (
<span className="ml-2 text-[12px] font-medium text-rubis-deep uppercase tracking-[0.1em]">
<span className="ml-2 text-[10.5px] font-medium text-rubis-deep uppercase tracking-[0.1em]">
· {state.subscriptionStatus}
</span>
)}
</p>
{state.currentPeriodEnd && (
<p className="mt-1 text-[12.5px] text-ink-3">
Prochaine facture le{" "}
<strong className="font-medium text-ink-2">
{formatDate(state.currentPeriodEnd)}
</strong>
{state.billingCycle === "yearly" ? " (annuel)" : " (mensuel)"}
</p>
)}
<p className="mt-0.5 text-[12px] text-ink-3 leading-tight">
{plan === "free" && inGracePeriod && gracePeriodEndsAt && (
<p className="mt-1 text-[12.5px] text-rubis-deep">
<Sparkles size={12} className="inline mr-1" aria-hidden="true" />
Période de grâce illimité jusqu'au{" "}
<strong className="font-semibold">
<>
Période illimitée jusqu'au{" "}
<strong className="text-ink-2 font-medium">
{formatDate(gracePeriodEndsAt)}
</strong>
</p>
</>
)}
</div>
{state.hasStripeCustomer && (
<Button
size="sm"
variant="secondary"
onClick={onOpenPortal}
loading={isOpeningPortal}
>
<CreditCard size={14} aria-hidden="true" /> Gérer
</Button>
)}
</div>
{/* Compteur de factures actives — visible pour Free uniquement */}
{isLimited && limit !== null && (
<div>
<div className="flex items-baseline justify-between mb-1">
<p className="text-[12.5px] text-ink-3">
Factures actives en relance
</p>
<p
{plan === "free" && !inGracePeriod && limit !== null && (
<>
<strong
className={cn(
"text-[12.5px] font-semibold tabular-nums",
"tabular-nums font-medium",
limitReached ? "text-rubis-deep" : "text-ink-2",
)}
>
{activeInvoicesCount} / {limit}
</strong>{" "}
factures actives
{limitReached && " — limite atteinte"}
</>
)}
{plan !== "free" && state.currentPeriodEnd && (
<>
Renouvellement le{" "}
<strong className="text-ink-2 font-medium">
{formatDate(state.currentPeriodEnd)}
</strong>
{state.billingCycle === "yearly" ? " (annuel)" : " (mensuel)"}
</>
)}
</p>
</div>
<div className="h-1.5 w-full rounded-full bg-cream-2 overflow-hidden">
{/* Mini progress bar mobile-OK pour Free */}
{isLimited && limit !== null && !inGracePeriod && (
<div className="hidden sm:block w-32 shrink-0">
<div className="h-1 w-full rounded-full bg-cream-2 overflow-hidden">
<div
className={cn(
"h-full transition-[width] duration-300",
@ -234,19 +217,25 @@ function CurrentPlanCard({
}}
/>
</div>
{limitReached && (
<p className="mt-2 text-[12.5px] text-rubis-deep font-medium">
Limite atteinte passez Pro pour ajouter de nouvelles factures.
</p>
)}
</div>
)}
</Card>
{state.hasStripeCustomer && (
<Button
size="sm"
variant="ghost"
onClick={onOpenPortal}
loading={isOpeningPortal}
>
<CreditCard size={13} aria-hidden="true" /> Gérer
</Button>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Toggle cycle
// Toggle cycle — mensuel par défaut, annuel à droite avec 17%
// ---------------------------------------------------------------------------
function CycleToggle({
@ -295,148 +284,221 @@ function CycleToggle({
}
// ---------------------------------------------------------------------------
// Cards plans (Free / Pro / Business)
// PlanFree — petite card sobre. Pas de pricing affiché en gros (= 0 €).
// ---------------------------------------------------------------------------
const PRICES: Record<
PlanKey,
{ monthly: number | null; yearly: number | null }
> = {
free: { monthly: 0, yearly: 0 },
pro: { monthly: 19, yearly: 190 },
business: { monthly: 49, yearly: 490 },
};
const FEATURES_BY_PLAN: Record<PlanKey, string[]> = {
free: [
"5 factures actives en relance",
"1 utilisateur",
"Plans de relance fournis",
"OCR illimité (3 premiers mois)",
],
pro: [
"Factures illimitées",
"OCR illimité",
"Plans custom + IA générative",
"Toutes les automatisations",
"1 utilisateur",
],
business: [
"Tout le plan Pro",
"Réponses depuis votre email pro",
"5 sièges utilisateurs",
"Support prioritaire",
"SMS (à venir)",
],
};
function PlanCard({
plan,
currentPlan,
cycle,
highlight = false,
ctaDisabled = false,
ctaLabel,
loading = false,
onUpgrade,
}: {
plan: PlanKey;
currentPlan?: PlanKey;
cycle: BillingCycle;
highlight?: boolean;
ctaDisabled?: boolean;
ctaLabel?: string;
loading?: boolean;
onUpgrade?: () => void;
}) {
const isCurrent = currentPlan === plan;
const price = PRICES[plan][cycle];
const features = FEATURES_BY_PLAN[plan];
const planIcon =
plan === "pro" ? <Zap size={16} /> : plan === "business" ? <Users size={16} /> : null;
function PlanFree({ current }: { current: boolean }) {
return (
<Card
padding="md"
className={cn(
"flex flex-col",
highlight && !isCurrent && "border-rubis shadow-card",
)}
>
<div className="flex items-center gap-2 mb-1">
{planIcon && <span className="text-rubis">{planIcon}</span>}
<p className="font-display text-[18px] font-bold text-ink">
{planLabel(plan)}
</p>
{highlight && !isCurrent && (
<span className="ml-auto text-[10px] uppercase tracking-[0.12em] font-semibold text-rubis bg-rubis-glow rounded-full px-2 py-0.5">
Recommandé
</span>
)}
{isCurrent && (
<span className="ml-auto text-[10px] uppercase tracking-[0.12em] font-semibold text-ink-3">
Actuel
</span>
)}
<div className="rounded-card border border-line bg-cream-2/40 p-6 flex flex-col">
<div className="flex items-baseline gap-2 mb-1">
<p className="font-display text-[16px] font-bold text-ink">Découverte</p>
{current && <CurrentBadge />}
</div>
<div className="mt-2 mb-5">
<p className="font-display tabular-nums">
<span className="text-[36px] font-bold tracking-[-0.02em] text-ink">
{price === 0 ? "0" : price}
</span>
<span className="text-[14px] font-medium text-ink-3 ml-1">
{cycle === "monthly" ? "€/mois" : "€/an"}
</span>
<p className="text-[11.5px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-5">
Gratuit
</p>
{price !== null && price > 0 && cycle === "yearly" && (
<p className="text-[11.5px] text-ink-3">
soit {(price / 12).toFixed(2).replace(".", ",")} /mois
</p>
)}
{plan === "free" && (
<p className="text-[11.5px] text-ink-3">3 mois illimités, puis 5 factures actives</p>
)}
</div>
<ul className="flex flex-col gap-2 mb-6 flex-1">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-2 text-[13px] text-ink-2 leading-snug"
>
<Check size={13} className="text-rubis shrink-0 mt-0.5" aria-hidden="true" />
<span>{f}</span>
</li>
))}
<p className="font-display text-[28px] font-bold leading-none text-ink mb-1">
0
</p>
<p className="text-[11.5px] text-ink-3 mb-5">3 mois illimités, puis 5 factures actives</p>
<ul className="flex flex-col gap-2.5 text-[12.5px] text-ink-2 leading-snug">
<FeatureLine>Tester sans poser sa CB</FeatureLine>
<FeatureLine>Plans de relance fournis</FeatureLine>
<FeatureLine>OCR + automatisations 3 mois</FeatureLine>
<FeatureLine>Au-delà : 5 factures à la fois</FeatureLine>
</ul>
<p className="mt-auto pt-6 text-[11px] italic text-ink-3 leading-snug">
Tout ce qu'il faut pour comprendre l'outil. Vous prendrez Pro
quand votre volume mensuel dépasse 5 factures à relancer.
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// PlanProHero — la pièce maîtresse. Bg cream + gem en filigrane, prix XL.
// ---------------------------------------------------------------------------
function PlanProHero({
current,
cycle,
loading,
onUpgrade,
}: {
current: boolean;
cycle: BillingCycle;
loading: boolean;
onUpgrade: () => void;
}) {
const monthlyPrice = cycle === "monthly" ? 19 : Math.round((190 / 12) * 100) / 100;
const totalLabel = cycle === "monthly" ? "facturé chaque mois" : "soit 190 € facturés annuellement";
return (
<Card padding="none" className="relative overflow-hidden flex flex-col p-7 border-rubis shadow-card">
{/* Gem en filigrane, bas-droite, très large pour devenir un motif */}
<Gem
aria-hidden="true"
size={280}
className="absolute -bottom-16 -right-16 text-rubis-glow opacity-70 pointer-events-none"
/>
{/* Pastille "Recommandé" */}
<div className="relative flex items-center gap-2 mb-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-rubis text-white px-2.5 py-0.5 text-[10.5px] font-bold uppercase tracking-[0.12em]">
<Gem size={10} className="text-white" />
Le plus pris
</span>
{current && <CurrentBadge />}
</div>
<p className="relative font-display text-[24px] font-bold tracking-[-0.018em] text-ink leading-tight">
Pour les TPE qui n'ont plus jamais à y penser
</p>
{/* Bloc prix — affiché de manière narrative */}
<div className="relative mt-5 mb-5">
<p className="font-display flex items-baseline">
<span className="text-[44px] font-bold tabular-nums tracking-[-0.025em] text-ink leading-none">
{monthlyPrice.toString().replace(".", ",")}
</span>
<span className="ml-2 text-[13px] font-medium text-ink-3">/ mois</span>
</p>
<p className="mt-1 text-[12px] text-ink-3">{totalLabel}</p>
</div>
{/* Liste de bénéfices, formulés en valeur (pas en feature spec) */}
<ul className="relative flex flex-col gap-2.5 text-[13px] text-ink-2 leading-snug mb-7">
<FeatureLine strong>Factures et relances illimitées</FeatureLine>
<FeatureLine>OCR sans limite de volume</FeatureLine>
<FeatureLine>Plans custom + génération IA des relances</FeatureLine>
<FeatureLine>Mode démo pour montrer l'outil aux prospects</FeatureLine>
<FeatureLine>Toutes les automatisations</FeatureLine>
</ul>
{!ctaDisabled && onUpgrade ? (
<Button
size="md"
variant={highlight ? "primary" : "secondary"}
variant="primary"
loading={loading}
disabled={isCurrent}
disabled={current}
onClick={onUpgrade}
className="w-full"
className="relative w-full"
>
{isCurrent ? (
{current ? (
"Plan actuel"
) : (
<>
Passer {planLabel(plan)} <ArrowRight size={14} aria-hidden="true" />
Passer Pro <ArrowRight size={14} aria-hidden="true" />
</>
)}
</Button>
) : (
<Button size="md" variant="ghost" disabled className="w-full">
{ctaLabel ?? "—"}
</Button>
)}
<p className="relative mt-3 text-[11px] italic text-ink-3 text-center leading-snug">
Moins cher qu'une heure de votre temps mensuel.
</p>
</Card>
);
}
function planLabel(plan: PlanKey): string {
return plan === "free" ? "Free" : plan === "pro" ? "Pro" : "Business";
// ---------------------------------------------------------------------------
// PlanBusiness — card sobre comme Free mais avec accents rubis-deep.
// ---------------------------------------------------------------------------
function PlanBusiness({
current,
cycle,
loading,
onUpgrade,
}: {
current: boolean;
cycle: BillingCycle;
loading: boolean;
onUpgrade: () => void;
}) {
const monthlyPrice = cycle === "monthly" ? 49 : Math.round((490 / 12) * 100) / 100;
const totalLabel = cycle === "monthly" ? "facturé chaque mois" : "soit 490 € facturés annuellement";
return (
<div className="rounded-card border border-line bg-white p-6 flex flex-col">
<div className="flex items-baseline gap-2 mb-1">
<p className="font-display text-[16px] font-bold text-ink">Équipe</p>
{current && <CurrentBadge />}
</div>
<p className="text-[11.5px] uppercase tracking-[0.12em] text-rubis-deep font-semibold mb-5">
Business
</p>
<p className="font-display flex items-baseline mb-1">
<span className="text-[28px] font-bold tabular-nums tracking-[-0.02em] text-ink leading-none">
{monthlyPrice.toString().replace(".", ",")}
</span>
<span className="ml-1.5 text-[12px] font-medium text-ink-3">/ mois</span>
</p>
<p className="text-[11.5px] text-ink-3 mb-5">{totalLabel}</p>
<ul className="flex flex-col gap-2.5 text-[12.5px] text-ink-2 leading-snug">
<FeatureLine strong>Tout Pro, plus :</FeatureLine>
<FeatureLine>5 sièges utilisateurs</FeatureLine>
<FeatureLine>Réponses depuis votre email pro</FeatureLine>
<FeatureLine>Support prioritaire</FeatureLine>
<FeatureLine subtle>SMS (à venir)</FeatureLine>
</ul>
<Button
size="sm"
variant="secondary"
loading={loading}
disabled={current}
onClick={onUpgrade}
className="mt-auto pt-6 w-full"
>
{current ? "Plan actuel" : "Passer Business"}
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Atomes
// ---------------------------------------------------------------------------
function FeatureLine({
children,
strong = false,
subtle = false,
}: {
children: React.ReactNode;
strong?: boolean;
subtle?: boolean;
}) {
return (
<li className="flex items-start gap-2">
{/* Petit losange rubis = bullet maison cohérent avec la marque */}
<span
aria-hidden="true"
className={cn(
"mt-1.5 size-[5px] rotate-45 shrink-0",
subtle ? "bg-ink-3/40" : "bg-rubis",
)}
/>
<span
className={cn(
strong && "font-semibold text-ink",
subtle && "text-ink-3 italic",
)}
>
{children}
</span>
</li>
);
}
function CurrentBadge() {
return (
<span className="text-[10px] uppercase tracking-[0.14em] font-semibold text-rubis bg-rubis-glow rounded-full px-2 py-0.5">
Actuel
</span>
);
}