feat(billing): page tarifaire /plan + uniformisation CTA "Voir les plans" (Sprint 5b)
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
04019f8348
commit
9edfbb3c95
13 changed files with 551 additions and 8 deletions
|
|
@ -7,3 +7,8 @@ VITE_ENABLE_T2_LIVE=false
|
||||||
|
|
||||||
# Optionnel — DSN Sentry pour monitoring prod (laisser commenté en dev local)
|
# Optionnel — DSN Sentry pour monitoring prod (laisser commenté en dev local)
|
||||||
# VITE_SENTRY_DSN=https://xxxxxx@o000000.ingest.sentry.io/0000000
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { QuestionnaireT1Page } from '@/features/simulations/pages/QuestionnaireT
|
||||||
import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page'
|
import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page'
|
||||||
import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
|
import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
|
||||||
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
|
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
|
||||||
|
import { PricingPage } from '@/features/billing/pages/PricingPage'
|
||||||
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
|
|
@ -89,7 +90,7 @@ export function AppRouter() {
|
||||||
<Route path="/progression" element={<ProgressionPage />} />
|
<Route path="/progression" element={<ProgressionPage />} />
|
||||||
<Route path="/methodologie" element={<ComingSoon />} />
|
<Route path="/methodologie" element={<ComingSoon />} />
|
||||||
<Route path="/historique" element={<HistoriquePage />} />
|
<Route path="/historique" element={<HistoriquePage />} />
|
||||||
<Route path="/plan" element={<ComingSoon />} />
|
<Route path="/plan" element={<PricingPage />} />
|
||||||
<Route path="/parametres" element={<ComingSoon />} />
|
<Route path="/parametres" element={<ComingSoon />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
135
src/features/billing/__tests__/PricingPage.test.tsx
Normal file
135
src/features/billing/__tests__/PricingPage.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<PricingPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
51
src/features/billing/api.ts
Normal file
51
src/features/billing/api.ts
Normal file
|
|
@ -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<CheckoutResponse> {
|
||||||
|
return apiFetch<CheckoutResponse>('/stripe/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { priceId: resolvePriceId(priceType), planName: priceType },
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomerPortalSession(): Promise<CustomerPortalResponse> {
|
||||||
|
return apiFetch<CustomerPortalResponse>('/stripe/customer-portal', {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
105
src/features/billing/components/PlanCard.tsx
Normal file
105
src/features/billing/components/PlanCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col gap-4 rounded-[var(--radius-lg)] border bg-surface p-6 shadow-card ${borderClass}`}
|
||||||
|
>
|
||||||
|
{currentBadge && (
|
||||||
|
<span className="absolute -top-3 left-6 inline-flex items-center rounded-full border border-brand/30 bg-brand-soft px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-brand-text">
|
||||||
|
Plan actuel
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-ink-primary">{title}</h2>
|
||||||
|
{description && <p className="mt-1 text-sm text-ink-secondary">{description}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold tracking-tight tabular-nums text-ink-primary">
|
||||||
|
{price}
|
||||||
|
</span>
|
||||||
|
{priceCadence && <span className="text-sm text-ink-tertiary">{priceCadence}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex-1 space-y-2">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-start gap-2 text-sm text-ink-primary">
|
||||||
|
<Check className="mt-0.5 size-4 shrink-0 text-brand-text" aria-hidden="true" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant={cta.variant}
|
||||||
|
size="md"
|
||||||
|
className="w-full"
|
||||||
|
disabled={cta.disabled}
|
||||||
|
loading={cta.loading}
|
||||||
|
onClick={cta.onClick}
|
||||||
|
>
|
||||||
|
{cta.label}
|
||||||
|
</Button>
|
||||||
|
{ctaHint && <p className="text-xs text-ink-tertiary">{ctaHint}</p>}
|
||||||
|
{errorMessage && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-danger/30 bg-danger-soft px-2.5 py-1.5 text-xs text-danger"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
src/features/billing/pages/PricingPage.tsx
Normal file
241
src/features/billing/pages/PricingPage.tsx
Normal file
|
|
@ -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<PriceType | null>(null)
|
||||||
|
const [errorByType, setErrorByType] = useState<Partial<Record<PriceType, string>>>({})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||||
|
<header className="mb-8">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
|
||||||
|
Tarifs
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-[32px] font-bold tracking-[-0.02em] text-ink-primary">
|
||||||
|
Choisissez votre plan
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-ink-secondary">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
{COLUMNS.map((col) => {
|
||||||
|
if (col.key === 'free') {
|
||||||
|
return (
|
||||||
|
<PlanCard
|
||||||
|
key={col.key}
|
||||||
|
title={col.title}
|
||||||
|
price={col.price}
|
||||||
|
description={col.description}
|
||||||
|
features={col.features}
|
||||||
|
highlighted={col.highlighted}
|
||||||
|
currentBadge={plan === 'free'}
|
||||||
|
cta={{
|
||||||
|
label: plan === 'free' ? 'Plan actuel' : 'Inférieur à votre plan',
|
||||||
|
variant: 'secondary',
|
||||||
|
disabled: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const config = ctaConfigs[col.key]
|
||||||
|
return (
|
||||||
|
<PlanCard
|
||||||
|
key={col.key}
|
||||||
|
title={col.title}
|
||||||
|
price={col.price}
|
||||||
|
priceCadence={col.priceCadence}
|
||||||
|
description={col.description}
|
||||||
|
features={col.features}
|
||||||
|
highlighted={col.highlighted}
|
||||||
|
currentBadge={plan === col.key}
|
||||||
|
cta={config.cta}
|
||||||
|
ctaHint={config.hint}
|
||||||
|
errorMessage={errorByType[col.key]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<p aria-live="polite" className="mt-6 text-center text-xs text-ink-tertiary">
|
||||||
|
Chargement de votre plan…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,7 @@ export function DashboardFreeView({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
|
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
|
||||||
Passer en Premium →
|
Voir les plans
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export function PaywallBanner() {
|
||||||
to="/plan"
|
to="/plan"
|
||||||
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md border border-border bg-surface px-4 text-sm font-semibold text-ink-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
|
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md border border-border bg-surface px-4 text-sm font-semibold text-ink-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
|
||||||
>
|
>
|
||||||
Voir les offres
|
Voir les plans
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
|
||||||
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
|
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
|
||||||
<p className="text-sm font-medium text-ink-primary">Historique disponible en Standard</p>
|
<p className="text-sm font-medium text-ink-primary">Historique disponible en Standard</p>
|
||||||
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
||||||
Passer en Standard
|
Voir les plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ describe('SimulationsList — gating Free', () => {
|
||||||
expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument()
|
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 user = userEvent.setup()
|
||||||
const onUpgrade = vi.fn()
|
const onUpgrade = vi.fn()
|
||||||
renderWithRouter(
|
renderWithRouter(
|
||||||
|
|
@ -79,7 +79,7 @@ describe('SimulationsList — gating Free', () => {
|
||||||
onUpgrade={onUpgrade}
|
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)
|
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
|
||||||
>
|
>
|
||||||
Vous avez utilisé vos 5 simulations gratuites.{' '}
|
Vous avez utilisé vos 5 simulations gratuites.{' '}
|
||||||
<a href="/pricing" className="underline underline-offset-4">
|
<a href="/pricing" className="underline underline-offset-4">
|
||||||
Passer en Standard
|
Voir les plans
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
pour continuer votre préparation.
|
pour continuer votre préparation.
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ function BlurredSection({
|
||||||
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
|
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
|
||||||
<p className="text-sm font-medium text-ink-primary">Disponible en Standard</p>
|
<p className="text-sm font-medium text-ink-primary">Disponible en Standard</p>
|
||||||
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
||||||
Passer en Standard
|
Voir les plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ const envSchema = z.object({
|
||||||
VITE_ENABLE_T2_LIVE: z.enum(['true', 'false']).optional(),
|
VITE_ENABLE_T2_LIVE: z.enum(['true', 'false']).optional(),
|
||||||
VITE_SENTRY_DSN: z.string().url().optional(),
|
VITE_SENTRY_DSN: z.string().url().optional(),
|
||||||
VITE_MAINTENANCE_MODE: z.enum(['true', 'false']).optional(),
|
VITE_MAINTENANCE_MODE: z.enum(['true', 'false']).optional(),
|
||||||
|
// Sprint 5b — price_ids Stripe (publics — visibles dans le dashboard Stripe).
|
||||||
|
// Optionnels : permet à la suite de tests Vitest de tourner sans config Stripe.
|
||||||
|
// Au runtime, l'absence déclenche une erreur explicite côté `features/billing/api.ts`.
|
||||||
|
VITE_STRIPE_PRICE_STANDARD: z.string().min(1).optional(),
|
||||||
|
VITE_STRIPE_PRICE_PREMIUM: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const parsed = envSchema.safeParse(import.meta.env)
|
const parsed = envSchema.safeParse(import.meta.env)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue