From 9edfbb3c95473b8e04a876b4ed33f88cebfa68c7 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sun, 26 Apr 2026 04:52:13 +0300 Subject: [PATCH] feat(billing): page tarifaire /plan + uniformisation CTA "Voir les plans" (Sprint 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - features/billing/{api,components/PlanCard,pages/PricingPage} + 5 tests - 3 colonnes Free/Standard/Premium avec gating dynamique selon usePlan() - POST /stripe/checkout avec redirect full-page Stripe Checkout - env: VITE_STRIPE_PRICE_STANDARD/_PREMIUM (optionnels) - router: /plan → PricingPage (sous PrivateLayout) - CTA renommés "Voir les plans" : SimulationsList, RapportPage, TaskSelector, DashboardFreeView, PaywallBanner — au lieu de CTA orientés un seul plan - Tests: 198 → 203 verts (+5) Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 5 + src/app/router.tsx | 3 +- .../billing/__tests__/PricingPage.test.tsx | 135 ++++++++++ src/features/billing/api.ts | 51 ++++ src/features/billing/components/PlanCard.tsx | 105 ++++++++ src/features/billing/pages/PricingPage.tsx | 241 ++++++++++++++++++ .../components/DashboardFreeView.tsx | 2 +- .../dashboard/components/PaywallBanner.tsx | 2 +- .../historique/components/SimulationsList.tsx | 2 +- .../__tests__/SimulationsList.test.tsx | 4 +- .../simulations/components/TaskSelector.tsx | 2 +- .../simulations/pages/RapportPage.tsx | 2 +- src/shared/config/env.ts | 5 + 13 files changed, 551 insertions(+), 8 deletions(-) create mode 100644 src/features/billing/__tests__/PricingPage.test.tsx create mode 100644 src/features/billing/api.ts create mode 100644 src/features/billing/components/PlanCard.tsx create mode 100644 src/features/billing/pages/PricingPage.tsx diff --git a/.env.example b/.env.example index 570d1d0..40d3f2a 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,8 @@ VITE_ENABLE_T2_LIVE=false # Optionnel — DSN Sentry pour monitoring prod (laisser commenté en dev local) # VITE_SENTRY_DSN=https://xxxxxx@o000000.ingest.sentry.io/0000000 + +# Sprint 5b — price_ids Stripe publics (Dashboard Stripe → Produits → Plan → Tarif). +# Requis en dev/prod ; absents en CI tests (tests mockent features/billing/api.ts). +VITE_STRIPE_PRICE_STANDARD=price_xxx +VITE_STRIPE_PRICE_PREMIUM=price_xxx diff --git a/src/app/router.tsx b/src/app/router.tsx index a8f50a7..5b0c693 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -17,6 +17,7 @@ import { QuestionnaireT1Page } from '@/features/simulations/pages/QuestionnaireT import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page' import { HistoriquePage } from '@/features/historique/pages/HistoriquePage' import { ProgressionPage } from '@/features/progression/pages/ProgressionPage' +import { PricingPage } from '@/features/billing/pages/PricingPage' import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider' import { AppLayout } from './AppLayout' @@ -89,7 +90,7 @@ export function AppRouter() { } /> } /> } /> - } /> + } /> } /> diff --git a/src/features/billing/__tests__/PricingPage.test.tsx b/src/features/billing/__tests__/PricingPage.test.tsx new file mode 100644 index 0000000..1f97047 --- /dev/null +++ b/src/features/billing/__tests__/PricingPage.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const { usePlanMock, createCheckoutSessionMock } = vi.hoisted(() => ({ + usePlanMock: vi.fn(), + createCheckoutSessionMock: vi.fn(), +})) + +vi.mock('@/features/dashboard/hooks/usePlan', () => ({ + usePlan: usePlanMock, +})) + +vi.mock('../api', () => ({ + createCheckoutSession: createCheckoutSessionMock, +})) + +import { PricingPage } from '../pages/PricingPage' + +afterEach(() => { + cleanup() + usePlanMock.mockReset() + createCheckoutSessionMock.mockReset() +}) + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +function mockPlan(plan: 'free' | 'standard' | 'premium') { + usePlanMock.mockReturnValue({ + data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null }, + isLoading: false, + isError: false, + }) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('PricingPage — plan free', () => { + it('CTA Standard et Premium actifs (plein tarif), Découverte = "Plan actuel" disabled', () => { + mockPlan('free') + renderPage() + + expect( + screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }), + ).toBeEnabled() + expect(screen.getByRole('button', { name: /Choisir Premium — 39,90 €\/4 sem\./ })).toBeEnabled() + + const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i }) + expect(planActuelButtons).toHaveLength(1) + expect(planActuelButtons[0]).toBeDisabled() + }) +}) + +describe('PricingPage — plan standard', () => { + it('Standard désactivé "Plan actuel" ; Premium actif "Passer en Premium" + hint prorata', () => { + mockPlan('standard') + renderPage() + + expect(screen.getByRole('button', { name: /Passer en Premium$/ })).toBeEnabled() + expect( + screen.queryByRole('button', { name: /Choisir Premium — 39,90 €/ }), + ).not.toBeInTheDocument() + + expect(screen.getByText(/Stripe calculera automatiquement le prorata/i)).toBeInTheDocument() + + const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i }) + expect(planActuelButtons).toHaveLength(1) + expect(planActuelButtons[0]).toBeDisabled() + }) +}) + +describe('PricingPage — plan premium', () => { + it('tous les CTA payants désactivés', () => { + mockPlan('premium') + renderPage() + + const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i }) + expect(planActuelButtons).toHaveLength(1) + expect(planActuelButtons[0]).toBeDisabled() + + expect( + screen.queryByRole('button', { name: /Choisir Standard|Passer en Premium|Choisir Premium/ }), + ).not.toBeInTheDocument() + + const inferieurButtons = screen.getAllByRole('button', { name: /Inférieur à votre plan/i }) + expect(inferieurButtons.length).toBeGreaterThanOrEqual(1) + inferieurButtons.forEach((btn) => expect(btn).toBeDisabled()) + }) +}) + +describe('PricingPage — interaction', () => { + it('clic sur "Choisir Standard" appelle createCheckoutSession("standard")', async () => { + const user = userEvent.setup() + mockPlan('free') + // Promesse non résolue : on veut juste vérifier l'appel, pas la redirection. + createCheckoutSessionMock.mockReturnValue(new Promise(() => {})) + + renderPage() + await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ })) + + await waitFor(() => { + expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1) + }) + // TanStack Query injecte un 2e arg (mutationContext) → on vérifie uniquement le 1er. + expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard') + }) + + it("erreur de mutation → callout d'erreur affiché", async () => { + const user = userEvent.setup() + mockPlan('free') + createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.')) + + renderPage() + await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/Configuration Stripe manquante/i) + }) + }) +}) diff --git a/src/features/billing/api.ts b/src/features/billing/api.ts new file mode 100644 index 0000000..4e259a7 --- /dev/null +++ b/src/features/billing/api.ts @@ -0,0 +1,51 @@ +/** + * Sprint 5b — API client billing. + * + * Wrappers TanStack-Query-friendly autour des endpoints Stripe : + * - `POST /stripe/checkout` (création session paiement plein tarif) + * - `POST /stripe/customer-portal` (Sprint 5d — Customer Portal Stripe) + * + * Le frontend ne stocke jamais de clé Stripe privée. Les `price_id` (publics + * par nature, comme la clé Supabase anon) sont injectés via les variables + * d'env `VITE_STRIPE_PRICE_*` — leur absence au runtime déclenche une erreur + * explicite côté CTA, pas un crash silencieux. + */ + +import { apiFetch } from '@/shared/lib/api-client' +import { env } from '@/shared/config/env' + +export type PriceType = 'standard' | 'premium' + +interface CheckoutResponse { + url: string +} + +interface CustomerPortalResponse { + url: string +} + +function resolvePriceId(priceType: PriceType): string { + const id = + priceType === 'standard' ? env.VITE_STRIPE_PRICE_STANDARD : env.VITE_STRIPE_PRICE_PREMIUM + if (!id) { + throw new Error( + 'Configuration Stripe manquante. Veuillez réessayer plus tard ou contacter le support.', + ) + } + return id +} + +export async function createCheckoutSession(priceType: PriceType): Promise { + return apiFetch('/stripe/checkout', { + method: 'POST', + body: { priceId: resolvePriceId(priceType), planName: priceType }, + timeoutMs: 30_000, + }) +} + +export async function createCustomerPortalSession(): Promise { + return apiFetch('/stripe/customer-portal', { + method: 'POST', + timeoutMs: 15_000, + }) +} diff --git a/src/features/billing/components/PlanCard.tsx b/src/features/billing/components/PlanCard.tsx new file mode 100644 index 0000000..8c13c57 --- /dev/null +++ b/src/features/billing/components/PlanCard.tsx @@ -0,0 +1,105 @@ +/** + * Carte plan tarifaire — Sprint 5b. + * + * Présentationnel pur (Règle H). Tokens DA Charcoal exclusivement (Règle L). + * La logique CTA (qui est désactivé, qui est "Plan actuel", etc.) vit dans + * `PricingPage.tsx`. + */ + +import { Check } from 'lucide-react' +import { Button } from '@/shared/ui/Button' + +export interface PlanCardCta { + label: string + variant: 'primary' | 'secondary' + disabled?: boolean + loading?: boolean + onClick?: () => void +} + +interface Props { + title: string + price: string + priceCadence?: string + description?: string + features: string[] + highlighted?: boolean + currentBadge?: boolean + cta: PlanCardCta + /** Texte additionnel sous le bouton (ex. info prorata). */ + ctaHint?: string + /** Message d'erreur affiché en bas de carte (ex. erreur mutation Stripe). */ + errorMessage?: string +} + +export function PlanCard({ + title, + price, + priceCadence, + description, + features, + highlighted = false, + currentBadge = false, + cta, + ctaHint, + errorMessage, +}: Props) { + const borderClass = highlighted + ? 'border-brand shadow-[0_0_0_1px_var(--color-brand)]' + : 'border-border' + + return ( +
+ {currentBadge && ( + + Plan actuel + + )} + +
+

{title}

+ {description &&

{description}

} +
+ +
+ + {price} + + {priceCadence && {priceCadence}} +
+ +
    + {features.map((feature) => ( +
  • +
  • + ))} +
+ +
+ + {ctaHint &&

{ctaHint}

} + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+
+ ) +} diff --git a/src/features/billing/pages/PricingPage.tsx b/src/features/billing/pages/PricingPage.tsx new file mode 100644 index 0000000..103389a --- /dev/null +++ b/src/features/billing/pages/PricingPage.tsx @@ -0,0 +1,241 @@ +/** + * Page tarifaire `/plan` — Sprint 5b. + * + * 3 colonnes (Découverte / Standard / Premium). Le CTA de chaque carte dépend + * du plan actuel de l'utilisateur : + * - free → CTA Standard et Premium actifs (plein tarif). + * - standard → Standard désactivé "Plan actuel" ; Premium = "Passer en Premium" + * (sans prix affiché — Stripe calcule le prorata côté serveur). + * - premium → Tous désactivés ; Premium marqué "Plan actuel". + * + * Le clic sur un CTA payant déclenche `createCheckoutSession(priceType)` puis + * redirige le navigateur en full-page vers l'URL Stripe Checkout retournée. + * + * Règle D : aucun `plan === 'xxx'` exposé — la sélection du CTA passe par + * une fonction `getCtaConfig(plan)` qui mappe explicitement chaque plan vers + * ses CTA, sans `if/else` éparpillés. + */ + +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { usePlan } from '@/features/dashboard/hooks/usePlan' +import { createCheckoutSession, type PriceType } from '../api' +import { PlanCard, type PlanCardCta } from '../components/PlanCard' + +type Plan = 'free' | 'standard' | 'premium' + +interface PlanColumn { + key: 'free' | 'standard' | 'premium' + title: string + price: string + priceCadence?: string + description: string + features: string[] + highlighted: boolean +} + +const COLUMNS: PlanColumn[] = [ + { + key: 'free', + title: 'Découverte', + price: 'Gratuit', + description: 'Goûter le produit, voir comment ça marche.', + features: [ + '5 simulations à vie', + 'Score global et niveau NCLC', + 'Feedback court (2-3 lignes)', + 'Accès EE T1, T2, T3 et EO T1, T3', + ], + highlighted: false, + }, + { + key: 'standard', + title: 'Standard', + price: '19,90 €', + priceCadence: '/ 4 semaines', + description: 'Progression sérieuse — toutes les corrections détaillées.', + features: [ + 'Simulations illimitées', + 'Rapport détaillé par critère', + 'Suggestions, exercices, production modèle', + 'Historique complet et dashboard', + 'Indice de préparation après 5 productions', + ], + highlighted: true, + }, + { + key: 'premium', + title: 'Premium', + price: '39,90 €', + priceCadence: '/ 4 semaines', + description: 'Tout Standard, plus les outils de simulation réelle.', + features: [ + 'Tout le plan Standard', + 'Mode Examen (60 min EE / 12 min EO)', + 'EO Tâche 2 — dialogue live avec l’examinateur IA', + 'Analyse des patterns sur 5 dernières productions', + 'Exercices long terme personnalisés', + ], + highlighted: false, + }, +] + +interface CtaConfigs { + standard: { cta: PlanCardCta; hint?: string } + premium: { cta: PlanCardCta; hint?: string } +} + +function buildCtaConfigs( + plan: Plan, + pendingType: PriceType | null, + onUpgrade: (priceType: PriceType) => void, +): CtaConfigs { + const isStandardPending = pendingType === 'standard' + const isPremiumPending = pendingType === 'premium' + const anyPending = pendingType !== null + + if (plan === 'free') { + return { + standard: { + cta: { + label: 'Choisir Standard — 19,90 €/4 sem.', + variant: 'primary', + loading: isStandardPending, + disabled: anyPending, + onClick: () => onUpgrade('standard'), + }, + }, + premium: { + cta: { + label: 'Choisir Premium — 39,90 €/4 sem.', + variant: 'primary', + loading: isPremiumPending, + disabled: anyPending, + onClick: () => onUpgrade('premium'), + }, + }, + } + } + + if (plan === 'standard') { + return { + standard: { + cta: { label: 'Plan actuel', variant: 'secondary', disabled: true }, + }, + premium: { + cta: { + label: 'Passer en Premium', + variant: 'primary', + loading: isPremiumPending, + disabled: anyPending, + onClick: () => onUpgrade('premium'), + }, + hint: 'Stripe calculera automatiquement le prorata sur votre abonnement en cours.', + }, + } + } + + // premium + return { + standard: { + cta: { label: 'Inférieur à votre plan', variant: 'secondary', disabled: true }, + }, + premium: { + cta: { label: 'Plan actuel', variant: 'secondary', disabled: true }, + }, + } +} + +export function PricingPage() { + const { data: planData, isLoading } = usePlan() + const [pendingType, setPendingType] = useState(null) + const [errorByType, setErrorByType] = useState>>({}) + + const mutation = useMutation({ + mutationFn: createCheckoutSession, + onSuccess: (data) => { + // Redirection full-page vers Stripe Checkout. L'utilisateur reviendra + // sur /dashboard?upgrade=success après paiement (cf. backend success_url). + window.location.href = data.url + }, + onError: (err: Error, priceType) => { + setErrorByType((prev) => ({ + ...prev, + [priceType]: + err.message || 'Impossible de démarrer le paiement. Réessayez dans quelques instants.', + })) + setPendingType(null) + }, + }) + + function handleUpgrade(priceType: PriceType) { + setErrorByType((prev) => ({ ...prev, [priceType]: undefined })) + setPendingType(priceType) + mutation.mutate(priceType) + } + + const plan = (planData?.plan as Plan | undefined) ?? 'free' + const ctaConfigs = buildCtaConfigs(plan, pendingType, handleUpgrade) + + return ( +
+
+

+ Tarifs +

+

+ Choisissez votre plan +

+

+ Toutes les offres incluent l’accès aux 5 tâches du TCF Canada (EE T1/T2/T3, EO T1/T3). + Annulation libre à tout moment depuis votre espace abonnement. +

+
+ +
+ {COLUMNS.map((col) => { + if (col.key === 'free') { + return ( + + ) + } + const config = ctaConfigs[col.key] + return ( + + ) + })} +
+ + {isLoading && ( +

+ Chargement de votre plan… +

+ )} +
+ ) +} diff --git a/src/features/dashboard/components/DashboardFreeView.tsx b/src/features/dashboard/components/DashboardFreeView.tsx index 4102b3d..3765d4d 100644 --- a/src/features/dashboard/components/DashboardFreeView.tsx +++ b/src/features/dashboard/components/DashboardFreeView.tsx @@ -54,7 +54,7 @@ export function DashboardFreeView({
diff --git a/src/features/historique/components/__tests__/SimulationsList.test.tsx b/src/features/historique/components/__tests__/SimulationsList.test.tsx index a36eeae..31a199d 100644 --- a/src/features/historique/components/__tests__/SimulationsList.test.tsx +++ b/src/features/historique/components/__tests__/SimulationsList.test.tsx @@ -66,7 +66,7 @@ describe('SimulationsList — gating Free', () => { expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument() }) - it('clic sur "Passer en Standard" appelle onUpgrade', async () => { + it('clic sur "Voir les plans" appelle onUpgrade', async () => { const user = userEvent.setup() const onUpgrade = vi.fn() renderWithRouter( @@ -79,7 +79,7 @@ describe('SimulationsList — gating Free', () => { onUpgrade={onUpgrade} />, ) - await user.click(screen.getByRole('button', { name: /passer en standard/i })) + await user.click(screen.getByRole('button', { name: /voir les plans/i })) expect(onUpgrade).toHaveBeenCalledTimes(1) }) }) diff --git a/src/features/simulations/components/TaskSelector.tsx b/src/features/simulations/components/TaskSelector.tsx index be31d2d..2dd7248 100644 --- a/src/features/simulations/components/TaskSelector.tsx +++ b/src/features/simulations/components/TaskSelector.tsx @@ -74,7 +74,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect > Vous avez utilisé vos 5 simulations gratuites.{' '} - Passer en Standard + Voir les plans {' '} pour continuer votre préparation. diff --git a/src/features/simulations/pages/RapportPage.tsx b/src/features/simulations/pages/RapportPage.tsx index 2eff19b..6a90d70 100644 --- a/src/features/simulations/pages/RapportPage.tsx +++ b/src/features/simulations/pages/RapportPage.tsx @@ -74,7 +74,7 @@ function BlurredSection({