feat(stripe): full subscription flow (checkout, portal, webhooks)
Backend:
- Prisma: add stripeSubscriptionId, subscriptionStatus, priceId,
currentPeriodEnd to User + migration SQL
- plugins/stripe.ts: getPlans catalog with env-based price IDs
- server.ts: raw body JSON parser for webhook signature verification,
skip rate limit on /stripe/webhook
- types/fastify.d.ts: declare rawBody on FastifyRequest
- routes/stripe.ts (new):
- GET /stripe/plans public
- GET /stripe/subscription user status
- POST /stripe/checkout hosted Checkout Session, lazy-creates
customer, dynamic payment methods, promo codes enabled
- POST /stripe/portal Billing Portal session
- POST /stripe/webhook signature verified, handles
checkout.session.completed, customer.subscription.*,
invoice.payment_failed. Resolves user by clientReferenceId,
metadata.userId, or stripeId fallback
- .env.example + README: Stripe setup, stripe CLI, test cards
Frontend:
- api/stripe.ts typed client (getPlans, getSubscription,
startCheckout, openPortal)
- pages/Pricing.tsx: 3-card grid (free/essentiel/premium) with
popular badge, current plan indicator, gradient popular card
- pages/CheckoutSuccess.tsx: animated confirmation with polling on
/stripe/subscription until webhook activates plan
- pages/Profile.tsx: SubscriptionCard above tabs — free users see an
upgrade banner, paid users see plan + status + next billing date
+ 'Gérer l'abonnement' button opening Customer Portal
- components/header.tsx: 'Tarifs' link in nav
- App.tsx: /pricing (public) and /checkout/success (protected) routes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0c4e0035c2
commit
339de4c44c
60
README.md
60
README.md
@ -8,7 +8,7 @@ Freedge génère des recettes personnalisées à partir des ingrédients dictés
|
|||||||
- **Backend** : Fastify 4 + TypeScript + Prisma 5 + SQLite
|
- **Backend** : Fastify 4 + TypeScript + Prisma 5 + SQLite
|
||||||
- **IA** : OpenAI (gpt-4o-mini-transcribe, gpt-4o-mini avec Structured Outputs, gpt-image-1)
|
- **IA** : OpenAI (gpt-4o-mini-transcribe, gpt-4o-mini avec Structured Outputs, gpt-image-1)
|
||||||
- **Stockage** : MinIO (S3-compatible) avec fallback local
|
- **Stockage** : MinIO (S3-compatible) avec fallback local
|
||||||
- **Paiement** : Stripe (client créé à l'inscription — intégration abonnement à finaliser)
|
- **Paiement** : Stripe Checkout hébergé + Customer Portal + webhooks signés
|
||||||
- **Auth** : JWT + Google OAuth
|
- **Auth** : JWT + Google OAuth
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
@ -116,10 +116,18 @@ npm run typecheck # vérification TS sans build
|
|||||||
|
|
||||||
### Recettes (`/recipes`) — toutes 🔒
|
### Recettes (`/recipes`) — toutes 🔒
|
||||||
- `POST /recipes/create` — Upload audio + transcription + génération
|
- `POST /recipes/create` — Upload audio + transcription + génération
|
||||||
|
- `POST /recipes/create-stream` — Version streaming SSE
|
||||||
- `GET /recipes/list` — Liste les recettes de l'utilisateur
|
- `GET /recipes/list` — Liste les recettes de l'utilisateur
|
||||||
- `GET /recipes/:id` — Détail d'une recette
|
- `GET /recipes/:id` — Détail d'une recette
|
||||||
- `DELETE /recipes/:id` — Supprime une recette
|
- `DELETE /recipes/:id` — Supprime une recette
|
||||||
|
|
||||||
|
### Stripe (`/stripe`)
|
||||||
|
- `GET /stripe/plans` — Liste publique des plans disponibles
|
||||||
|
- `GET /stripe/subscription` — Statut d'abonnement de l'utilisateur 🔒
|
||||||
|
- `POST /stripe/checkout` — Crée une Checkout Session (body: `{ plan }`) 🔒
|
||||||
|
- `POST /stripe/portal` — Ouvre le Customer Portal 🔒
|
||||||
|
- `POST /stripe/webhook` — Receiver d'événements Stripe (signature vérifiée)
|
||||||
|
|
||||||
### Divers
|
### Divers
|
||||||
- `GET /health` — Healthcheck
|
- `GET /health` — Healthcheck
|
||||||
|
|
||||||
@ -172,6 +180,56 @@ audio (multipart)
|
|||||||
- **Image best-effort** : un échec de génération d'image ne casse pas la
|
- **Image best-effort** : un échec de génération d'image ne casse pas la
|
||||||
création de recette.
|
création de recette.
|
||||||
|
|
||||||
|
## Stripe — configuration
|
||||||
|
|
||||||
|
### 1. Créer les produits et prix
|
||||||
|
|
||||||
|
Dans le [dashboard Stripe](https://dashboard.stripe.com/test/products) :
|
||||||
|
1. Crée deux produits récurrents :
|
||||||
|
- **Essentiel** — 3€/mois
|
||||||
|
- **Premium** — 5€/mois
|
||||||
|
2. Note les **Price IDs** (commencent par `price_...`).
|
||||||
|
|
||||||
|
### 2. Configurer le Customer Portal
|
||||||
|
|
||||||
|
Dans [Settings → Billing → Customer Portal](https://dashboard.stripe.com/test/settings/billing/portal) :
|
||||||
|
- Active **Cancel subscriptions** (pour permettre l'annulation)
|
||||||
|
- Active **Switch plans** et ajoute les 2 produits (pour permettre le changement)
|
||||||
|
- Active **Update payment methods**
|
||||||
|
- Dans "Branding", renseigne le nom Freedge et l'URL de support
|
||||||
|
|
||||||
|
### 3. Variables d'environnement backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
STRIPE_PRICE_ID_ESSENTIAL=price_...
|
||||||
|
STRIPE_PRICE_ID_PREMIUM=price_...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Tester les webhooks en local
|
||||||
|
|
||||||
|
Installe la CLI Stripe : `brew install stripe/stripe-cli/stripe`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stripe login
|
||||||
|
stripe listen --forward-to localhost:3000/stripe/webhook
|
||||||
|
# → affiche "whsec_..." à coller dans STRIPE_WEBHOOK_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans un autre terminal, tu peux simuler un événement :
|
||||||
|
```bash
|
||||||
|
stripe trigger checkout.session.completed
|
||||||
|
stripe trigger customer.subscription.updated
|
||||||
|
stripe trigger customer.subscription.deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Cartes de test
|
||||||
|
|
||||||
|
Utilise les numéros fournis par Stripe dans le [dashboard test](https://docs.stripe.com/testing) :
|
||||||
|
- `4242 4242 4242 4242` → paiement réussi
|
||||||
|
- `4000 0000 0000 9995` → carte déclinée
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
- Helmet + rate-limit (100 req/min) activés
|
- Helmet + rate-limit (100 req/min) activés
|
||||||
|
|||||||
@ -39,8 +39,16 @@ OPENAI_IMAGE_SIZE=1024x1024
|
|||||||
OPENAI_MAX_RETRIES=3
|
OPENAI_MAX_RETRIES=3
|
||||||
OPENAI_TIMEOUT_MS=60000
|
OPENAI_TIMEOUT_MS=60000
|
||||||
|
|
||||||
# ---- Stripe (optionnel) ----
|
# ---- Stripe ----
|
||||||
|
# Clé secrète (commence par sk_test_ en dev, sk_live_ en prod)
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
# Secret webhook (affiché par `stripe listen --forward-to localhost:3000/stripe/webhook`)
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
# IDs des prix récurrents créés dans le dashboard Stripe
|
||||||
|
STRIPE_PRICE_ID_ESSENTIAL=price_...
|
||||||
|
STRIPE_PRICE_ID_PREMIUM=price_...
|
||||||
|
# Version d'API (facultatif, défaut = 2023-10-16)
|
||||||
|
# STRIPE_API_VERSION=2023-10-16
|
||||||
|
|
||||||
# ---- MinIO (démarré avec `docker-compose up -d` depuis la racine du projet) ----
|
# ---- MinIO (démarré avec `docker-compose up -d` depuis la racine du projet) ----
|
||||||
# Laisse vide pour désactiver et utiliser uniquement le stockage local ./uploads
|
# Laisse vide pour désactiver et utiliser uniquement le stockage local ./uploads
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
-- Add Stripe subscription tracking fields to User
|
||||||
|
|
||||||
|
ALTER TABLE "User" ADD COLUMN "stripeSubscriptionId" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "subscriptionStatus" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "subscriptionPriceId" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "subscriptionCurrentPeriodEnd" DATETIME;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "User_stripeSubscriptionId_key" ON "User"("stripeSubscriptionId");
|
||||||
@ -13,11 +13,17 @@ model User {
|
|||||||
password String? // Optionnel pour les utilisateurs Google
|
password String? // Optionnel pour les utilisateurs Google
|
||||||
name String
|
name String
|
||||||
googleId String? @unique
|
googleId String? @unique
|
||||||
stripeId String? @unique // Optionnel : Stripe peut être désactivé
|
|
||||||
subscription String?
|
|
||||||
resetToken String?
|
resetToken String?
|
||||||
resetTokenExpiry DateTime?
|
resetTokenExpiry DateTime?
|
||||||
|
|
||||||
|
// ---- Stripe / Abonnement ----
|
||||||
|
stripeId String? @unique // Customer ID Stripe
|
||||||
|
subscription String? // 'free' | 'essential' | 'premium'
|
||||||
|
stripeSubscriptionId String? @unique
|
||||||
|
subscriptionStatus String? // active | trialing | past_due | canceled | incomplete
|
||||||
|
subscriptionPriceId String?
|
||||||
|
subscriptionCurrentPeriodEnd DateTime?
|
||||||
|
|
||||||
// Préférences culinaires injectées dans le prompt IA
|
// Préférences culinaires injectées dans le prompt IA
|
||||||
dietaryPreference String? // 'vegetarian' | 'vegan' | 'pescatarian' | 'none'
|
dietaryPreference String? // 'vegetarian' | 'vegan' | 'pescatarian' | 'none'
|
||||||
allergies String? // Liste séparée par des virgules : "arachides,gluten"
|
allergies String? // Liste séparée par des virgules : "arachides,gluten"
|
||||||
|
|||||||
@ -2,6 +2,62 @@ import fp from 'fastify-plugin';
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalogue des plans supportés par l'application. Les `priceId` sont
|
||||||
|
* injectés via les variables d'environnement pour faciliter le passage
|
||||||
|
* test → prod sans redéploiement.
|
||||||
|
*/
|
||||||
|
export interface StripePlan {
|
||||||
|
id: 'essential' | 'premium';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
priceId: string | undefined;
|
||||||
|
monthlyRecipes: number | null;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlans(): StripePlan[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'essential',
|
||||||
|
name: 'Essentiel',
|
||||||
|
description: 'Pour les cuisiniers réguliers',
|
||||||
|
priceId: process.env.STRIPE_PRICE_ID_ESSENTIAL,
|
||||||
|
monthlyRecipes: 15,
|
||||||
|
features: [
|
||||||
|
'15 recettes par mois',
|
||||||
|
'Reconnaissance vocale des ingrédients',
|
||||||
|
'Préférences culinaires',
|
||||||
|
'Sauvegarde des recettes',
|
||||||
|
'Support par email',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'premium',
|
||||||
|
name: 'Premium',
|
||||||
|
description: 'Pour les passionnés de cuisine',
|
||||||
|
priceId: process.env.STRIPE_PRICE_ID_PREMIUM,
|
||||||
|
monthlyRecipes: null,
|
||||||
|
features: [
|
||||||
|
'Recettes illimitées',
|
||||||
|
'Reconnaissance vocale des ingrédients',
|
||||||
|
'Préférences culinaires',
|
||||||
|
'Sauvegarde des recettes',
|
||||||
|
'Images haute qualité',
|
||||||
|
'Support prioritaire',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlanById(id: string): StripePlan | undefined {
|
||||||
|
return getPlans().find((p) => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlanByPriceId(priceId: string): StripePlan | undefined {
|
||||||
|
return getPlans().find((p) => p.priceId === priceId);
|
||||||
|
}
|
||||||
|
|
||||||
export default fp(async function stripePlugin(fastify: FastifyInstance) {
|
export default fp(async function stripePlugin(fastify: FastifyInstance) {
|
||||||
const key = process.env.STRIPE_SECRET_KEY;
|
const key = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@ -10,13 +66,15 @@ export default fp(async function stripePlugin(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stripe = new Stripe(key, {
|
const stripe = new Stripe(key, {
|
||||||
apiVersion: (process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16',
|
apiVersion:
|
||||||
|
(process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16',
|
||||||
|
typescript: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.decorate('stripe', stripe);
|
fastify.decorate('stripe', stripe);
|
||||||
|
|
||||||
fastify.decorate('createCustomer', (email: string, name: string) =>
|
fastify.decorate('createCustomer', (email: string, name: string) =>
|
||||||
stripe.customers.create({ email, name })
|
stripe.customers.create({ email, name, metadata: { source: 'freedge' } })
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.decorate('createSubscription', (customerId: string, priceId: string) =>
|
fastify.decorate('createSubscription', (customerId: string, priceId: string) =>
|
||||||
|
|||||||
348
backend/src/routes/stripe.ts
Normal file
348
backend/src/routes/stripe.ts
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import type Stripe from 'stripe';
|
||||||
|
import { getPlans, getPlanById, getPlanByPriceId } from '../plugins/stripe';
|
||||||
|
|
||||||
|
interface CheckoutBody {
|
||||||
|
plan: 'essential' | 'premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||||
|
const authenticate = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
try {
|
||||||
|
await fastify.authenticate(request, reply);
|
||||||
|
} catch {
|
||||||
|
reply.code(401).send({ error: 'Authentification requise' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /stripe/plans — Liste publique des plans (pas besoin d'auth)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
fastify.get('/plans', async () => {
|
||||||
|
const plans = getPlans().map((plan) => ({
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
description: plan.description,
|
||||||
|
monthlyRecipes: plan.monthlyRecipes,
|
||||||
|
features: plan.features,
|
||||||
|
// priceId est omis : info sensible, pas utile au frontend
|
||||||
|
available: !!plan.priceId,
|
||||||
|
}));
|
||||||
|
return { plans };
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /stripe/subscription — Statut d'abonnement de l'utilisateur courant
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
fastify.get(
|
||||||
|
'/subscription',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const user = await fastify.prisma.user.findUnique({
|
||||||
|
where: { id: request.user.id },
|
||||||
|
select: {
|
||||||
|
subscription: true,
|
||||||
|
subscriptionStatus: true,
|
||||||
|
subscriptionPriceId: true,
|
||||||
|
subscriptionCurrentPeriodEnd: true,
|
||||||
|
stripeSubscriptionId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = user.subscription || 'free';
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
status: user.subscriptionStatus ?? (plan === 'free' ? 'none' : null),
|
||||||
|
currentPeriodEnd: user.subscriptionCurrentPeriodEnd,
|
||||||
|
hasActiveSubscription: !!user.stripeSubscriptionId,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err);
|
||||||
|
return reply.code(500).send({ error: 'Erreur lors de la récupération' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /stripe/checkout — Crée une Checkout Session et renvoie l'URL
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
fastify.post<{ Body: CheckoutBody }>(
|
||||||
|
'/checkout',
|
||||||
|
{
|
||||||
|
preHandler: authenticate,
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['plan'],
|
||||||
|
properties: {
|
||||||
|
plan: { type: 'string', enum: ['essential', 'premium'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!fastify.stripe) {
|
||||||
|
return reply.code(503).send({ error: 'Stripe n\'est pas configuré' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const planConfig = getPlanById(request.body.plan);
|
||||||
|
if (!planConfig || !planConfig.priceId) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: `Plan "${request.body.plan}" non disponible. Vérifiez que STRIPE_PRICE_ID_${request.body.plan.toUpperCase()} est défini.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await fastify.prisma.user.findUnique({
|
||||||
|
where: { id: request.user.id },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crée le customer Stripe si absent (ancien utilisateurs, Stripe auparavant désactivé)
|
||||||
|
let customerId = user.stripeId;
|
||||||
|
if (!customerId) {
|
||||||
|
if (!fastify.createCustomer) {
|
||||||
|
return reply.code(503).send({ error: 'Stripe non initialisé' });
|
||||||
|
}
|
||||||
|
const customer = await fastify.createCustomer(user.email, user.name);
|
||||||
|
customerId = customer.id;
|
||||||
|
await fastify.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { stripeId: customerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
|
const session = await fastify.stripe.checkout.sessions.create({
|
||||||
|
mode: 'subscription',
|
||||||
|
customer: customerId,
|
||||||
|
client_reference_id: user.id,
|
||||||
|
line_items: [{ price: planConfig.priceId, quantity: 1 }],
|
||||||
|
success_url: `${appUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${appUrl}/pricing`,
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
billing_address_collection: 'auto',
|
||||||
|
// Pas de payment_method_types : on laisse Stripe choisir
|
||||||
|
// dynamiquement selon la région de l'utilisateur (meilleure
|
||||||
|
// conversion). Active les moyens de paiement depuis le dashboard.
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
plan: planConfig.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
plan: planConfig.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url: session.url, sessionId: session.id };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err, 'checkout session creation failed');
|
||||||
|
return reply.code(500).send({ error: 'Erreur lors de la création de la session' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /stripe/portal — Redirige vers le Customer Portal (gestion abo)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
fastify.post(
|
||||||
|
'/portal',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!fastify.stripe) {
|
||||||
|
return reply.code(503).send({ error: 'Stripe n\'est pas configuré' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await fastify.prisma.user.findUnique({
|
||||||
|
where: { id: request.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.stripeId) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Aucun compte de facturation. Commence par souscrire un abonnement.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
|
const portalSession = await fastify.stripe.billingPortal.sessions.create({
|
||||||
|
customer: user.stripeId,
|
||||||
|
return_url: `${appUrl}/profile`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url: portalSession.url };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err, 'portal session creation failed');
|
||||||
|
return reply.code(500).send({ error: 'Erreur lors de la création du portail' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /stripe/webhook — Receiver d'événements Stripe (signature vérifiée)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
fastify.post('/webhook', async (request, reply) => {
|
||||||
|
if (!fastify.stripe) {
|
||||||
|
return reply.code(503).send({ error: 'Stripe non configuré' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = request.headers['stripe-signature'];
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
if (!signature || typeof signature !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'Signature manquante' });
|
||||||
|
}
|
||||||
|
if (!webhookSecret) {
|
||||||
|
fastify.log.error('STRIPE_WEBHOOK_SECRET non défini');
|
||||||
|
return reply.code(500).send({ error: 'Webhook non configuré' });
|
||||||
|
}
|
||||||
|
if (!request.rawBody) {
|
||||||
|
fastify.log.error('Raw body non disponible — vérifier le content-type parser');
|
||||||
|
return reply.code(400).send({ error: 'Raw body indisponible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = fastify.stripe.webhooks.constructEvent(
|
||||||
|
request.rawBody,
|
||||||
|
signature,
|
||||||
|
webhookSecret
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.warn(`Webhook signature invalide: ${(err as Error).message}`);
|
||||||
|
return reply.code(400).send({ error: 'Signature invalide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fastify.log.info({ type: event.type, id: event.id }, 'stripe_webhook_received');
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'checkout.session.completed': {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
// La session de checkout est terminée. On attrape tout de suite
|
||||||
|
// la subscription associée pour avoir le priceId + dates.
|
||||||
|
if (session.mode === 'subscription' && session.subscription) {
|
||||||
|
const subscriptionId =
|
||||||
|
typeof session.subscription === 'string'
|
||||||
|
? session.subscription
|
||||||
|
: session.subscription.id;
|
||||||
|
const subscription = await fastify.stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
await applySubscriptionToUser(fastify, subscription, session.client_reference_id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'customer.subscription.created':
|
||||||
|
case 'customer.subscription.updated': {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
await applySubscriptionToUser(fastify, subscription, null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'customer.subscription.deleted': {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
await fastify.prisma.user.updateMany({
|
||||||
|
where: { stripeSubscriptionId: subscription.id },
|
||||||
|
data: {
|
||||||
|
subscription: 'free',
|
||||||
|
subscriptionStatus: 'canceled',
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
subscriptionPriceId: null,
|
||||||
|
subscriptionCurrentPeriodEnd: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'invoice.payment_failed': {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
const subscriptionId =
|
||||||
|
typeof invoice.subscription === 'string' ? invoice.subscription : null;
|
||||||
|
if (subscriptionId) {
|
||||||
|
await fastify.prisma.user.updateMany({
|
||||||
|
where: { stripeSubscriptionId: subscriptionId },
|
||||||
|
data: { subscriptionStatus: 'past_due' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// On ignore silencieusement les événements qui ne nous intéressent pas
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err, `webhook handler failed for ${event.type}`);
|
||||||
|
// On renvoie 500 pour que Stripe retente — sauf si c'est une erreur
|
||||||
|
// de validation métier qui ne sera pas résolue par un retry
|
||||||
|
return reply.code(500).send({ error: 'Handler failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { received: true };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique l'état d'une Subscription Stripe sur l'utilisateur correspondant.
|
||||||
|
* Résout l'utilisateur via (dans l'ordre) :
|
||||||
|
* 1. `clientReferenceId` (posé par checkout.session.completed)
|
||||||
|
* 2. `subscription.metadata.userId`
|
||||||
|
* 3. `customer` (stripeId)
|
||||||
|
*/
|
||||||
|
async function applySubscriptionToUser(
|
||||||
|
fastify: FastifyInstance,
|
||||||
|
subscription: Stripe.Subscription,
|
||||||
|
clientReferenceId: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
const customerId =
|
||||||
|
typeof subscription.customer === 'string'
|
||||||
|
? subscription.customer
|
||||||
|
: subscription.customer.id;
|
||||||
|
|
||||||
|
const priceId = subscription.items.data[0]?.price.id;
|
||||||
|
const plan = priceId ? getPlanByPriceId(priceId) : undefined;
|
||||||
|
|
||||||
|
let userId: string | null = clientReferenceId;
|
||||||
|
if (!userId && subscription.metadata?.userId) {
|
||||||
|
userId = subscription.metadata.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
stripeSubscriptionId: subscription.id,
|
||||||
|
subscriptionStatus: subscription.status,
|
||||||
|
subscriptionPriceId: priceId ?? null,
|
||||||
|
subscriptionCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
||||||
|
subscription: plan?.id ?? 'essential', // fallback safe
|
||||||
|
stripeId: customerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await fastify.prisma.user.update({ where: { id: userId }, data }).catch(async () => {
|
||||||
|
// L'utilisateur a pu être supprimé entre-temps, ou l'ID est invalide.
|
||||||
|
// On retombe sur une résolution par customerId.
|
||||||
|
await fastify.prisma.user.updateMany({
|
||||||
|
where: { stripeId: customerId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fastify.prisma.user.updateMany({
|
||||||
|
where: { stripeId: customerId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default stripeRoutes;
|
||||||
@ -18,6 +18,7 @@ import googleAuthPlugin from './plugins/google-auth';
|
|||||||
import authRoutes from './routes/auth';
|
import authRoutes from './routes/auth';
|
||||||
import recipesRoutes from './routes/recipes';
|
import recipesRoutes from './routes/recipes';
|
||||||
import usersRoutes from './routes/users';
|
import usersRoutes from './routes/users';
|
||||||
|
import stripeRoutes from './routes/stripe';
|
||||||
|
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@ -26,6 +27,27 @@ const fastify = Fastify({
|
|||||||
bodyLimit: 10 * 1024 * 1024, // 10 MB
|
bodyLimit: 10 * 1024 * 1024, // 10 MB
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Parser JSON custom : on stashe le body brut sur la request pour permettre
|
||||||
|
// la vérification de signature du webhook Stripe (qui exige les bytes exacts).
|
||||||
|
// Impact: ~2x mémoire sur les requêtes JSON, négligeable à notre échelle.
|
||||||
|
fastify.addContentTypeParser(
|
||||||
|
'application/json',
|
||||||
|
{ parseAs: 'string' },
|
||||||
|
(req, body, done) => {
|
||||||
|
try {
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
req.rawBody = body;
|
||||||
|
const json = body.length > 0 ? JSON.parse(body) : {};
|
||||||
|
done(null, json);
|
||||||
|
} else {
|
||||||
|
done(null, body);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
done(err as Error, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
fastify.decorate('prisma', prisma);
|
fastify.decorate('prisma', prisma);
|
||||||
|
|
||||||
@ -42,6 +64,9 @@ async function bootstrap(): Promise<void> {
|
|||||||
await fastify.register(rateLimit, {
|
await fastify.register(rateLimit, {
|
||||||
max: 100,
|
max: 100,
|
||||||
timeWindow: '1 minute',
|
timeWindow: '1 minute',
|
||||||
|
// Les webhooks Stripe peuvent arriver en rafale sur une retry burst —
|
||||||
|
// on ne veut pas les rate-limiter et risquer des événements perdus.
|
||||||
|
skip: (req) => req.url === '/stripe/webhook',
|
||||||
});
|
});
|
||||||
|
|
||||||
const allowedOrigins = (
|
const allowedOrigins = (
|
||||||
@ -79,6 +104,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
await fastify.register(authRoutes, { prefix: '/auth' });
|
await fastify.register(authRoutes, { prefix: '/auth' });
|
||||||
await fastify.register(recipesRoutes, { prefix: '/recipes' });
|
await fastify.register(recipesRoutes, { prefix: '/recipes' });
|
||||||
await fastify.register(usersRoutes, { prefix: '/users' });
|
await fastify.register(usersRoutes, { prefix: '/users' });
|
||||||
|
await fastify.register(stripeRoutes, { prefix: '/stripe' });
|
||||||
|
|
||||||
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
|
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
|
||||||
|
|
||||||
|
|||||||
7
backend/src/types/fastify.d.ts
vendored
7
backend/src/types/fastify.d.ts
vendored
@ -13,6 +13,13 @@ declare module '@fastify/jwt' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** Body brut préservé pour la vérification de signature Stripe webhook */
|
||||||
|
rawBody?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecipeData {
|
export interface RecipeData {
|
||||||
titre: string;
|
titre: string;
|
||||||
ingredients: string | string[];
|
ingredients: string | string[];
|
||||||
|
|||||||
@ -5,6 +5,9 @@ const REQUIRED = ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY'] as const;
|
|||||||
|
|
||||||
const OPTIONAL_WARN = [
|
const OPTIONAL_WARN = [
|
||||||
'STRIPE_SECRET_KEY',
|
'STRIPE_SECRET_KEY',
|
||||||
|
'STRIPE_WEBHOOK_SECRET',
|
||||||
|
'STRIPE_PRICE_ID_ESSENTIAL',
|
||||||
|
'STRIPE_PRICE_ID_PREMIUM',
|
||||||
'MINIO_ENDPOINT',
|
'MINIO_ENDPOINT',
|
||||||
'MINIO_PORT',
|
'MINIO_PORT',
|
||||||
'MINIO_ACCESS_KEY',
|
'MINIO_ACCESS_KEY',
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import RecipeForm from '@/pages/Recipes/RecipeForm'
|
|||||||
import Profile from './pages/Profile'
|
import Profile from './pages/Profile'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import ResetPassword from '@/pages/ResetPassword'
|
import ResetPassword from '@/pages/ResetPassword'
|
||||||
|
import Pricing from '@/pages/Pricing'
|
||||||
|
import CheckoutSuccess from '@/pages/CheckoutSuccess'
|
||||||
import { MainLayout } from './layouts/MainLayout'
|
import { MainLayout } from './layouts/MainLayout'
|
||||||
import useAuth from '@/hooks/useAuth'
|
import useAuth from '@/hooks/useAuth'
|
||||||
import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards'
|
import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards'
|
||||||
@ -40,6 +42,10 @@ function App() {
|
|||||||
{/* Profil */}
|
{/* Profil */}
|
||||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||||
|
|
||||||
|
{/* Abonnement */}
|
||||||
|
<Route path="/pricing" element={<Pricing />} />
|
||||||
|
<Route path="/checkout/success" element={<ProtectedRoute><CheckoutSuccess /></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* Racine */}
|
{/* Racine */}
|
||||||
<Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} />
|
<Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} />
|
||||||
|
|
||||||
|
|||||||
45
frontend/src/api/stripe.ts
Normal file
45
frontend/src/api/stripe.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { apiService } from "./base";
|
||||||
|
|
||||||
|
export interface StripePlan {
|
||||||
|
id: "essential" | "premium";
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
monthlyRecipes: number | null;
|
||||||
|
features: string[];
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionStatus {
|
||||||
|
plan: "free" | "essential" | "premium";
|
||||||
|
status: string | null;
|
||||||
|
currentPeriodEnd: string | null;
|
||||||
|
hasActiveSubscription: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeService = {
|
||||||
|
/** Récupère la liste des plans disponibles */
|
||||||
|
getPlans: (): Promise<{ plans: StripePlan[] }> => apiService.get("/stripe/plans"),
|
||||||
|
|
||||||
|
/** Récupère le statut d'abonnement de l'utilisateur courant */
|
||||||
|
getSubscription: (): Promise<SubscriptionStatus> =>
|
||||||
|
apiService.get("/stripe/subscription"),
|
||||||
|
|
||||||
|
/** Démarre un Checkout Stripe — redirige vers l'URL renvoyée */
|
||||||
|
startCheckout: async (plan: "essential" | "premium"): Promise<void> => {
|
||||||
|
const { url } = await apiService.post<{ url: string; sessionId: string }>(
|
||||||
|
"/stripe/checkout",
|
||||||
|
{ plan }
|
||||||
|
);
|
||||||
|
if (!url) throw new Error("Pas d'URL de checkout reçue");
|
||||||
|
window.location.href = url;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Ouvre le Customer Portal pour gérer l'abonnement */
|
||||||
|
openPortal: async (): Promise<void> => {
|
||||||
|
const { url } = await apiService.post<{ url: string }>("/stripe/portal", {});
|
||||||
|
if (!url) throw new Error("Pas d'URL de portail reçue");
|
||||||
|
window.location.href = url;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default stripeService;
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Link, useLocation } from "react-router-dom"
|
import { Link, useLocation } from "react-router-dom"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Menu, X, LogOut, User, Heart, Home, BookOpen } from "lucide-react"
|
import { Menu, X, LogOut, User, Home, BookOpen, Sparkles } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { Logo } from "@/components/illustrations/Logo"
|
import { Logo } from "@/components/illustrations/Logo"
|
||||||
@ -40,8 +40,7 @@ export function Header() {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Accueil", path: "/", icon: Home, public: true },
|
{ name: "Accueil", path: "/", icon: Home, public: true },
|
||||||
{ name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
|
{ name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
|
||||||
// { name: "Mes recettes", path: "/recipes", icon: BookOpen, public: false },
|
{ name: "Tarifs", path: "/pricing", icon: Sparkles, public: true },
|
||||||
// { name: "Favoris", path: "/favorites", icon: Heart, public: false },
|
|
||||||
{ name: "Profil", path: "/profile", icon: User, public: false },
|
{ name: "Profil", path: "/profile", icon: User, public: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
151
frontend/src/pages/CheckoutSuccess.tsx
Normal file
151
frontend/src/pages/CheckoutSuccess.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { CheckCircle2, Sparkles, ArrowRight, ChefHat } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import stripeService, { type SubscriptionStatus } from "@/api/stripe"
|
||||||
|
|
||||||
|
export default function CheckoutSuccess() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null)
|
||||||
|
const [polling, setPolling] = useState(true)
|
||||||
|
|
||||||
|
// Poll /stripe/subscription jusqu'à ce que le webhook ait activé l'abo
|
||||||
|
// (max 10 tentatives × 1.5s = 15s, ce qui couvre largement la latence webhook)
|
||||||
|
useEffect(() => {
|
||||||
|
let attempts = 0
|
||||||
|
const maxAttempts = 10
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
attempts++
|
||||||
|
try {
|
||||||
|
const sub = await stripeService.getSubscription()
|
||||||
|
setSubscription(sub)
|
||||||
|
if (sub.hasActiveSubscription || attempts >= maxAttempts) {
|
||||||
|
clearInterval(interval)
|
||||||
|
setPolling(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(interval)
|
||||||
|
setPolling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const planName =
|
||||||
|
subscription?.plan === "essential"
|
||||||
|
? "Essentiel"
|
||||||
|
: subscription?.plan === "premium"
|
||||||
|
? "Premium"
|
||||||
|
: "Premium"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-12 md:py-20 text-center">
|
||||||
|
{/* Icône succès animée */}
|
||||||
|
<motion.div
|
||||||
|
className="relative mx-auto mb-8 flex h-28 w-28 items-center justify-center"
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||||
|
>
|
||||||
|
{/* Halo */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-full bg-gradient-to-br from-emerald-300/50 to-emerald-500/30 blur-2xl"
|
||||||
|
animate={{ scale: [1, 1.2, 1], opacity: [0.6, 1, 0.6] }}
|
||||||
|
transition={{ duration: 2.5, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
{/* Étincelles */}
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
top: ["0%", "0%", "100%", "100%"][i],
|
||||||
|
left: ["0%", "100%", "0%", "100%"][i],
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
scale: [0, 1, 0],
|
||||||
|
opacity: [0, 1, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: i * 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 text-amber-400" />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
{/* Check principal */}
|
||||||
|
<div className="relative z-10 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 text-white shadow-2xl shadow-emerald-500/40 ring-4 ring-white dark:ring-slate-900">
|
||||||
|
<CheckCircle2 className="h-14 w-14" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-2">
|
||||||
|
Bienvenue dans{" "}
|
||||||
|
<span className="text-warm-gradient">{planName}</span>
|
||||||
|
{" "}
|
||||||
|
<Sparkles className="inline h-6 w-6 md:h-8 md:w-8 text-amber-400" />
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-base md:text-lg max-w-md mx-auto mt-3">
|
||||||
|
Ton abonnement est actif. Tu peux dès maintenant profiter de toutes les
|
||||||
|
fonctionnalités premium.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{polling && !subscription?.hasActiveSubscription && (
|
||||||
|
<motion.p
|
||||||
|
className="mt-6 text-sm text-muted-foreground"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.8 }}
|
||||||
|
>
|
||||||
|
<span className="animate-pulse">Activation de ton abonnement…</span>
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="mt-10 flex flex-col sm:flex-row gap-3 justify-center"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<Link to="/recipes/new">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full sm:w-auto h-12 px-6 rounded-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md shadow-orange-500/25 hover:shadow-lg hover:shadow-orange-500/40 transition-all font-semibold group"
|
||||||
|
>
|
||||||
|
<ChefHat className="mr-2 h-5 w-5" />
|
||||||
|
Créer ma première recette
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto h-12 px-6 rounded-full"
|
||||||
|
onClick={() => navigate("/profile")}
|
||||||
|
>
|
||||||
|
Gérer mon compte
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
className="mt-12 text-xs text-muted-foreground"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.8 }}
|
||||||
|
>
|
||||||
|
Un reçu a été envoyé à ton adresse email.
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
frontend/src/pages/Pricing.tsx
Normal file
302
frontend/src/pages/Pricing.tsx
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { Check, Sparkles, Zap, ArrowLeft, Loader2 } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import stripeService, { type StripePlan, type SubscriptionStatus } from "@/api/stripe"
|
||||||
|
import useAuth from "@/hooks/useAuth"
|
||||||
|
|
||||||
|
export default function Pricing() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const [plans, setPlans] = useState<StripePlan[]>([])
|
||||||
|
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [{ plans }, sub] = await Promise.all([
|
||||||
|
stripeService.getPlans(),
|
||||||
|
isAuthenticated
|
||||||
|
? stripeService.getSubscription().catch(() => null)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
])
|
||||||
|
setPlans(plans)
|
||||||
|
setSubscription(sub)
|
||||||
|
} catch {
|
||||||
|
setError("Impossible de charger les plans")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
const handleCheckout = async (planId: "essential" | "premium") => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate("/auth/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCheckoutLoading(planId)
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
await stripeService.startCheckout(planId)
|
||||||
|
// Redirection gérée par startCheckout
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur de paiement")
|
||||||
|
setCheckoutLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan statique "Gratuit" (pas dans les plans Stripe)
|
||||||
|
const freePlan = {
|
||||||
|
id: "free" as const,
|
||||||
|
name: "Gratuit",
|
||||||
|
description: "Pour découvrir Freedge",
|
||||||
|
features: [
|
||||||
|
"5 recettes au total",
|
||||||
|
"Reconnaissance vocale",
|
||||||
|
"Recettes personnalisées",
|
||||||
|
"Sauvegarde des recettes",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlan = subscription?.plan || "free"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 md:px-8 py-6 md:py-12">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-full -ml-2 gap-1.5 hover:bg-white/60 dark:hover:bg-slate-800/60"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<motion.div
|
||||||
|
className="text-center mb-12"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-orange-100 to-amber-100 dark:from-orange-900/40 dark:to-amber-900/40 px-3 py-1 text-xs font-medium text-orange-700 dark:text-orange-300 border border-orange-200/60 dark:border-orange-800/40 mb-4">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
Choisis ton plan
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">
|
||||||
|
Trouve le plan qui te va
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-3 max-w-xl mx-auto text-base md:text-lg">
|
||||||
|
Change ou annule à tout moment. Pas d'engagement, pas de frais cachés.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 max-w-md mx-auto rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-800 text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plans grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-[500px] rounded-2xl bg-muted/60 animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Plan gratuit */}
|
||||||
|
<PlanCard
|
||||||
|
plan={{
|
||||||
|
...freePlan,
|
||||||
|
price: "0€",
|
||||||
|
cta: currentPlan === "free" ? "Plan actuel" : "Rester gratuit",
|
||||||
|
disabled: currentPlan === "free",
|
||||||
|
current: currentPlan === "free",
|
||||||
|
}}
|
||||||
|
onClick={() => navigate("/recipes/new")}
|
||||||
|
loading={false}
|
||||||
|
index={0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Plans Stripe */}
|
||||||
|
{plans.map((plan, i) => {
|
||||||
|
const isCurrent = currentPlan === plan.id
|
||||||
|
const isPopular = plan.id === "essential"
|
||||||
|
return (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={{
|
||||||
|
...plan,
|
||||||
|
price: plan.id === "essential" ? "3€" : "5€",
|
||||||
|
period: "/mois",
|
||||||
|
cta: isCurrent ? "Plan actuel" : `Choisir ${plan.name}`,
|
||||||
|
disabled: isCurrent || !plan.available,
|
||||||
|
current: isCurrent,
|
||||||
|
popular: isPopular,
|
||||||
|
}}
|
||||||
|
onClick={() => handleCheckout(plan.id)}
|
||||||
|
loading={checkoutLoading === plan.id}
|
||||||
|
index={i + 1}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAQ / notes */}
|
||||||
|
<motion.div
|
||||||
|
className="mt-16 text-center text-sm text-muted-foreground max-w-2xl mx-auto space-y-2"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
🔒 Les paiements sont traités de façon sécurisée par Stripe. Nous ne stockons
|
||||||
|
aucune information bancaire.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Tu peux changer de plan ou annuler à tout moment depuis ton profil.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PlanCardProps {
|
||||||
|
plan: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
features: string[]
|
||||||
|
price: string
|
||||||
|
period?: string
|
||||||
|
cta: string
|
||||||
|
disabled: boolean
|
||||||
|
current: boolean
|
||||||
|
popular?: boolean
|
||||||
|
}
|
||||||
|
onClick: () => void
|
||||||
|
loading: boolean
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan, onClick, loading, index }: PlanCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1, duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
className={`relative rounded-2xl p-6 md:p-8 flex flex-col ${
|
||||||
|
plan.popular
|
||||||
|
? "bg-gradient-to-br from-orange-500 to-amber-500 text-white shadow-2xl shadow-orange-500/30 md:-translate-y-4"
|
||||||
|
: "bg-card/80 backdrop-blur-sm border border-border/60 shadow-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.popular && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<Badge className="bg-white text-orange-600 border-0 shadow-md px-3 py-1">
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
Le plus populaire
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan.current && (
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
plan.popular
|
||||||
|
? "bg-white/20 text-white border-0 backdrop-blur-sm"
|
||||||
|
: "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Plan actuel
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className={`text-xl font-bold ${plan.popular ? "text-white" : "text-foreground"}`}
|
||||||
|
>
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className={`text-sm mt-1 ${plan.popular ? "text-white/80" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-baseline gap-1">
|
||||||
|
<span
|
||||||
|
className={`text-5xl font-bold tracking-tight ${plan.popular ? "text-white" : ""}`}
|
||||||
|
>
|
||||||
|
{plan.price}
|
||||||
|
</span>
|
||||||
|
{plan.period && (
|
||||||
|
<span
|
||||||
|
className={`text-sm ${plan.popular ? "text-white/70" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{plan.period}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-6 space-y-3 flex-1">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li key={feature} className="flex gap-2 text-sm">
|
||||||
|
<Check
|
||||||
|
className={`h-5 w-5 shrink-0 ${
|
||||||
|
plan.popular ? "text-white" : "text-emerald-500"
|
||||||
|
}`}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
<span className={plan.popular ? "text-white/90" : "text-foreground/90"}>
|
||||||
|
{feature}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={plan.disabled || loading}
|
||||||
|
className={`mt-8 w-full h-12 rounded-full font-semibold transition-all ${
|
||||||
|
plan.popular
|
||||||
|
? "bg-white text-orange-600 hover:bg-white/90 shadow-lg"
|
||||||
|
: plan.current
|
||||||
|
? "bg-muted text-muted-foreground cursor-default hover:bg-muted"
|
||||||
|
: "bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md hover:shadow-lg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Redirection…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{plan.cta}
|
||||||
|
{!plan.disabled && !plan.popular && <Zap className="ml-2 h-4 w-4" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -31,11 +31,15 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
import { recipeService, Recipe } from "@/api/recipe";
|
import { recipeService, Recipe } from "@/api/recipe";
|
||||||
import userService from "@/api/user";
|
import userService from "@/api/user";
|
||||||
|
import stripeService, { type SubscriptionStatus } from "@/api/stripe";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@ -85,6 +89,8 @@ export default function Profile() {
|
|||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deletePassword, setDeletePassword] = useState("");
|
const [deletePassword, setDeletePassword] = useState("");
|
||||||
|
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null);
|
||||||
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
|
|
||||||
const [profileForm, setProfileForm] = useState({ name: "" });
|
const [profileForm, setProfileForm] = useState({ name: "" });
|
||||||
const [passwordForm, setPasswordForm] = useState({
|
const [passwordForm, setPasswordForm] = useState({
|
||||||
@ -120,6 +126,9 @@ export default function Profile() {
|
|||||||
});
|
});
|
||||||
const recipes = await recipeService.getRecipes();
|
const recipes = await recipeService.getRecipes();
|
||||||
setUserRecipes(recipes);
|
setUserRecipes(recipes);
|
||||||
|
|
||||||
|
// Charge aussi l'état d'abonnement (non-bloquant)
|
||||||
|
stripeService.getSubscription().then(setSubscription).catch(() => {/* ignore */});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erreur lors du chargement du profil:", err);
|
console.error("Erreur lors du chargement du profil:", err);
|
||||||
setError("Impossible de charger les données du profil");
|
setError("Impossible de charger les données du profil");
|
||||||
@ -250,6 +259,17 @@ export default function Profile() {
|
|||||||
navigate("/auth/login");
|
navigate("/auth/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenPortal = async () => {
|
||||||
|
setPortalLoading(true);
|
||||||
|
try {
|
||||||
|
await stripeService.openPortal();
|
||||||
|
// Redirection gérée par openPortal
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Impossible d'ouvrir le portail de facturation");
|
||||||
|
setPortalLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = async () => {
|
const handleDeleteAccount = async () => {
|
||||||
try {
|
try {
|
||||||
await userService.deleteAccount(deletePassword);
|
await userService.deleteAccount(deletePassword);
|
||||||
@ -441,6 +461,13 @@ export default function Profile() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* --- Subscription card --- */}
|
||||||
|
<SubscriptionCard
|
||||||
|
subscription={subscription}
|
||||||
|
onManage={handleOpenPortal}
|
||||||
|
portalLoading={portalLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* --- Tabs --- */}
|
{/* --- Tabs --- */}
|
||||||
<Tabs defaultValue="cuisine" className="w-full">
|
<Tabs defaultValue="cuisine" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-4 bg-muted/60 backdrop-blur-sm p-1 h-auto">
|
<TabsList className="grid w-full grid-cols-4 bg-muted/60 backdrop-blur-sm p-1 h-auto">
|
||||||
@ -918,3 +945,136 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SubscriptionCard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SubscriptionCardProps {
|
||||||
|
subscription: SubscriptionStatus | null;
|
||||||
|
onManage: () => void;
|
||||||
|
portalLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionCard({ subscription, onManage, portalLoading }: SubscriptionCardProps) {
|
||||||
|
// État de chargement
|
||||||
|
if (!subscription) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 h-24 rounded-2xl bg-muted/60 animate-pulse" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFree = subscription.plan === "free";
|
||||||
|
const isPremium = subscription.plan === "premium";
|
||||||
|
const planName =
|
||||||
|
subscription.plan === "essential"
|
||||||
|
? "Essentiel"
|
||||||
|
: subscription.plan === "premium"
|
||||||
|
? "Premium"
|
||||||
|
: "Gratuit";
|
||||||
|
|
||||||
|
const nextBilling = subscription.currentPeriodEnd
|
||||||
|
? new Date(subscription.currentPeriodEnd).toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: "Actif",
|
||||||
|
trialing: "Période d'essai",
|
||||||
|
past_due: "Paiement en retard",
|
||||||
|
canceled: "Annulé",
|
||||||
|
incomplete: "Incomplet",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFree) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-6 rounded-2xl border border-border/60 bg-gradient-to-br from-orange-50 via-amber-50 to-yellow-50 dark:from-orange-950/30 dark:via-amber-950/20 dark:to-yellow-950/10 p-5 md:p-6 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
|
<div className="h-12 w-12 shrink-0 rounded-xl bg-gradient-to-br from-orange-500 to-amber-500 flex items-center justify-center shadow-md shadow-orange-500/30 mx-auto md:mx-0">
|
||||||
|
<Sparkles className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<h3 className="font-semibold text-base md:text-lg">
|
||||||
|
Passe à l'étape supérieure
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Recettes illimitées, images HD, support prioritaire. À partir de 3€/mois.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/pricing" className="w-full md:w-auto">
|
||||||
|
<Button className="w-full h-11 px-6 rounded-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md hover:shadow-lg transition-all font-semibold">
|
||||||
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
|
Voir les plans
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan payant
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`mb-6 rounded-2xl border p-5 md:p-6 backdrop-blur-sm ${
|
||||||
|
isPremium
|
||||||
|
? "border-amber-200/60 dark:border-amber-800/40 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20"
|
||||||
|
: "border-border/60 bg-card/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
|
<div className="h-12 w-12 shrink-0 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-md shadow-orange-500/30 mx-auto md:mx-0">
|
||||||
|
<Sparkles className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<div className="flex items-center gap-2 justify-center md:justify-start flex-wrap">
|
||||||
|
<h3 className="font-semibold text-base md:text-lg">Plan {planName}</h3>
|
||||||
|
{subscription.status && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
subscription.status === "active"
|
||||||
|
? "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300"
|
||||||
|
: "bg-orange-100 text-orange-700 border-orange-200"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusLabels[subscription.status] ?? subscription.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{nextBilling
|
||||||
|
? `Prochaine facturation le ${nextBilling}`
|
||||||
|
: "Abonnement actif"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onManage}
|
||||||
|
disabled={portalLoading}
|
||||||
|
className="w-full md:w-auto h-11 px-6 rounded-full border-orange-200 hover:bg-orange-50 hover:border-orange-300 dark:border-orange-800/50 dark:hover:bg-orange-950/30"
|
||||||
|
>
|
||||||
|
{portalLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Ouverture…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CreditCard className="mr-2 h-4 w-4" />
|
||||||
|
Gérer l'abonnement
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user