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)
|
||||
# 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 { 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() {
|
|||
<Route path="/progression" element={<ProgressionPage />} />
|
||||
<Route path="/methodologie" element={<ComingSoon />} />
|
||||
<Route path="/historique" element={<HistoriquePage />} />
|
||||
<Route path="/plan" element={<ComingSoon />} />
|
||||
<Route path="/plan" element={<PricingPage />} />
|
||||
<Route path="/parametres" element={<ComingSoon />} />
|
||||
</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 className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
|
||||
Passer en Premium →
|
||||
Voir les plans
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function PaywallBanner() {
|
|||
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"
|
||||
>
|
||||
Voir les offres
|
||||
Voir les plans
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
|
|||
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-ink-primary">Historique disponible en Standard</p>
|
||||
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
||||
Passer en Standard
|
||||
Voir les plans
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
|
|||
>
|
||||
Vous avez utilisé vos 5 simulations gratuites.{' '}
|
||||
<a href="/pricing" className="underline underline-offset-4">
|
||||
Passer en Standard
|
||||
Voir les plans
|
||||
</a>{' '}
|
||||
pour continuer votre préparation.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ function BlurredSection({
|
|||
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-ink-primary">Disponible en Standard</p>
|
||||
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
||||
Passer en Standard
|
||||
Voir les plans
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ const envSchema = z.object({
|
|||
VITE_ENABLE_T2_LIVE: z.enum(['true', 'false']).optional(),
|
||||
VITE_SENTRY_DSN: z.string().url().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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue