Compare commits
10 Commits
f4b3339fe4
...
21c92abc9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c92abc9c | ||
| 3bff3c8600 | |||
|
|
1b3f53c086 | ||
|
|
b783627890 | ||
|
|
b0e9425ed5 | ||
|
|
8d1202ca34 | ||
|
|
64d2bf4506 | ||
|
|
339de4c44c | ||
|
|
0c4e0035c2 | ||
|
|
7a3cbb9385 |
102
.gitea/workflows/deploy.yml
Normal file
102
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,102 @@
|
||||
name: Build & Deploy to K3s
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.arthurbarre.fr
|
||||
BACKEND_IMAGE: git.arthurbarre.fr/ordinarthur/freedge-backend
|
||||
FRONTEND_IMAGE: git.arthurbarre.fr/ordinarthur/freedge-frontend
|
||||
REGISTRY_USER: ordinarthur
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin
|
||||
|
||||
- name: Build backend image
|
||||
run: |
|
||||
docker build \
|
||||
-t ${{ env.BACKEND_IMAGE }}:${{ github.sha }} \
|
||||
-t ${{ env.BACKEND_IMAGE }}:latest \
|
||||
./backend
|
||||
|
||||
- name: Build frontend image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL=https://freedge.app/api \
|
||||
--build-arg VITE_GOOGLE_CLIENT_ID=173866668387-i18igc0e1avqtsaqq6nig898bv6pvuk6.apps.googleusercontent.com \
|
||||
-t ${{ env.FRONTEND_IMAGE }}:${{ github.sha }} \
|
||||
-t ${{ env.FRONTEND_IMAGE }}:latest \
|
||||
./frontend
|
||||
|
||||
- name: Push backend image
|
||||
run: |
|
||||
docker push ${{ env.BACKEND_IMAGE }}:${{ github.sha }}
|
||||
docker push ${{ env.BACKEND_IMAGE }}:latest
|
||||
|
||||
- name: Push frontend image
|
||||
run: |
|
||||
docker push ${{ env.FRONTEND_IMAGE }}:${{ github.sha }}
|
||||
docker push ${{ env.FRONTEND_IMAGE }}:latest
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/kubectl
|
||||
|
||||
- name: Configure kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
|
||||
|
||||
- name: Apply namespace and shared resources
|
||||
run: |
|
||||
kubectl apply -f k8s/namespace.yml
|
||||
kubectl apply -f k8s/configmap.yml
|
||||
kubectl apply -f k8s/pvc.yml
|
||||
kubectl apply -f k8s/service.yml
|
||||
|
||||
- name: Create image pull secret
|
||||
run: |
|
||||
kubectl -n freedge create secret docker-registry gitea-registry-secret \
|
||||
--docker-server=${{ env.REGISTRY }} \
|
||||
--docker-username=${{ env.REGISTRY_USER }} \
|
||||
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Create app secrets
|
||||
run: |
|
||||
kubectl -n freedge create secret generic freedge-secrets \
|
||||
--from-literal=DATABASE_URL="${{ secrets.DATABASE_URL }}" \
|
||||
--from-literal=JWT_SECRET="${{ secrets.JWT_SECRET }}" \
|
||||
--from-literal=OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}" \
|
||||
--from-literal=STRIPE_SECRET_KEY="${{ secrets.STRIPE_SECRET_KEY }}" \
|
||||
--from-literal=STRIPE_WEBHOOK_SECRET="${{ secrets.STRIPE_WEBHOOK_SECRET }}" \
|
||||
--from-literal=STRIPE_PRICE_ID_ESSENTIAL="${{ secrets.STRIPE_PRICE_ID_ESSENTIAL }}" \
|
||||
--from-literal=STRIPE_PRICE_ID_PREMIUM="${{ secrets.STRIPE_PRICE_ID_PREMIUM }}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Deploy workloads
|
||||
run: |
|
||||
kubectl apply -f k8s/deployment.yml
|
||||
kubectl -n freedge set image deployment/freedge-backend \
|
||||
freedge-backend=${{ env.BACKEND_IMAGE }}:${{ github.sha }}
|
||||
kubectl -n freedge set image deployment/freedge-frontend \
|
||||
freedge-frontend=${{ env.FRONTEND_IMAGE }}:${{ github.sha }}
|
||||
kubectl -n freedge rollout status deployment/freedge-backend --timeout=180s
|
||||
kubectl -n freedge rollout status deployment/freedge-frontend --timeout=180s
|
||||
kubectl -n freedge rollout status deployment/freedge-proxy --timeout=180s
|
||||
|
||||
- name: Cleanup old images
|
||||
run: |
|
||||
docker image prune -f
|
||||
73
README.md
73
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
|
||||
- **IA** : OpenAI (gpt-4o-mini-transcribe, gpt-4o-mini avec Structured Outputs, gpt-image-1)
|
||||
- **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
|
||||
|
||||
## Structure
|
||||
@ -116,10 +116,18 @@ npm run typecheck # vérification TS sans build
|
||||
|
||||
### Recettes (`/recipes`) — toutes 🔒
|
||||
- `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/:id` — Détail d'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
|
||||
- `GET /health` — Healthcheck
|
||||
|
||||
@ -172,6 +180,69 @@ audio (multipart)
|
||||
- **Image best-effort** : un échec de génération d'image ne casse pas la
|
||||
création de recette.
|
||||
|
||||
## Stripe — configuration
|
||||
|
||||
### 1. Créer les produits et prix
|
||||
|
||||
**Option A — script automatique (recommandé)**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
# Mets juste STRIPE_SECRET_KEY dans .env, puis :
|
||||
npm run stripe:setup # affiche les IDs à copier
|
||||
# ou :
|
||||
npm run stripe:setup:write # écrit directement dans backend/.env
|
||||
```
|
||||
|
||||
Le script est idempotent (utilise des `lookup_key` sur les prix) : tu peux le relancer autant de fois que tu veux, il ne créera pas de doublons.
|
||||
|
||||
**Option B — manuel, via 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_...`) et colle-les dans `backend/.env`.
|
||||
|
||||
### 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é
|
||||
|
||||
- Helmet + rate-limit (100 req/min) activés
|
||||
|
||||
@ -39,8 +39,16 @@ OPENAI_IMAGE_SIZE=1024x1024
|
||||
OPENAI_MAX_RETRIES=3
|
||||
OPENAI_TIMEOUT_MS=60000
|
||||
|
||||
# ---- Stripe (optionnel) ----
|
||||
# ---- Stripe ----
|
||||
# Clé secrète (commence par sk_test_ en dev, sk_live_ en prod)
|
||||
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) ----
|
||||
# Laisse vide pour désactiver et utiliser uniquement le stockage local ./uploads
|
||||
|
||||
27
backend/Dockerfile
Normal file
27
backend/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/uploads ./uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/server.js"]
|
||||
@ -10,6 +10,10 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"migrate": "prisma migrate dev",
|
||||
"studio": "prisma studio",
|
||||
"stripe:setup": "tsx scripts/setup-stripe.ts",
|
||||
"stripe:setup:write": "tsx scripts/setup-stripe.ts --write-env",
|
||||
"stripe:listen": "stripe listen --forward-to localhost:3000/stripe/webhook",
|
||||
"stripe:sync": "tsx scripts/sync-subscription.ts",
|
||||
"lint": "eslint src",
|
||||
"format": "prettier --write \"src/**/*.{ts,json}\""
|
||||
},
|
||||
|
||||
@ -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");
|
||||
@ -3,7 +3,7 @@ generator client {
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
@ -13,11 +13,17 @@ model User {
|
||||
password String? // Optionnel pour les utilisateurs Google
|
||||
name String
|
||||
googleId String? @unique
|
||||
stripeId String? @unique // Optionnel : Stripe peut être désactivé
|
||||
subscription String?
|
||||
resetToken String?
|
||||
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
|
||||
dietaryPreference String? // 'vegetarian' | 'vegan' | 'pescatarian' | 'none'
|
||||
allergies String? // Liste séparée par des virgules : "arachides,gluten"
|
||||
@ -49,4 +55,4 @@ model Recipe {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
256
backend/scripts/setup-stripe.ts
Normal file
256
backend/scripts/setup-stripe.ts
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Script d'initialisation Stripe.
|
||||
*
|
||||
* Crée (ou récupère s'ils existent déjà) les produits et prix de Freedge,
|
||||
* puis affiche les IDs à copier dans `.env`. Idempotent — peut être relancé
|
||||
* sans danger grâce aux `lookup_key` sur les prix.
|
||||
*
|
||||
* Usage :
|
||||
* pnpm stripe:setup # juste afficher les IDs
|
||||
* pnpm stripe:setup --write-env # écrire directement dans backend/.env
|
||||
*
|
||||
* Nécessite `STRIPE_SECRET_KEY` dans l'environnement (chargé depuis .env).
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import Stripe from 'stripe';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration des plans à créer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PlanDefinition {
|
||||
envKey: 'STRIPE_PRICE_ID_ESSENTIAL' | 'STRIPE_PRICE_ID_PREMIUM';
|
||||
lookupKey: string;
|
||||
product: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
price: {
|
||||
unitAmount: number; // centimes
|
||||
currency: string;
|
||||
interval: 'month' | 'year';
|
||||
};
|
||||
}
|
||||
|
||||
const PLANS: PlanDefinition[] = [
|
||||
{
|
||||
envKey: 'STRIPE_PRICE_ID_ESSENTIAL',
|
||||
lookupKey: 'freedge_essential_monthly',
|
||||
product: {
|
||||
name: 'Freedge Essentiel',
|
||||
description:
|
||||
'15 recettes par mois, reconnaissance vocale, préférences culinaires, sauvegarde illimitée.',
|
||||
},
|
||||
price: {
|
||||
unitAmount: 300, // 3,00 €
|
||||
currency: 'eur',
|
||||
interval: 'month',
|
||||
},
|
||||
},
|
||||
{
|
||||
envKey: 'STRIPE_PRICE_ID_PREMIUM',
|
||||
lookupKey: 'freedge_premium_monthly',
|
||||
product: {
|
||||
name: 'Freedge Premium',
|
||||
description:
|
||||
'Recettes illimitées, images haute qualité, reconnaissance vocale, support prioritaire.',
|
||||
},
|
||||
price: {
|
||||
unitAmount: 500, // 5,00 €
|
||||
currency: 'eur',
|
||||
interval: 'month',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const YELLOW = '\x1b[33m';
|
||||
const GREEN = '\x1b[32m';
|
||||
const RED = '\x1b[31m';
|
||||
const CYAN = '\x1b[36m';
|
||||
const DIM = '\x1b[2m';
|
||||
const BOLD = '\x1b[1m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
function log(msg: string) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
function fail(msg: string): never {
|
||||
log(`${RED}✗ ${msg}${RESET}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un prix existant par `lookup_key`, ou crée le produit + prix
|
||||
* sinon. Retourne toujours un objet Stripe.Price.
|
||||
*/
|
||||
async function ensurePlan(
|
||||
stripe: Stripe,
|
||||
plan: PlanDefinition
|
||||
): Promise<Stripe.Price> {
|
||||
// Cherche par lookup_key (idempotent)
|
||||
const existing = await stripe.prices.list({
|
||||
lookup_keys: [plan.lookupKey],
|
||||
expand: ['data.product'],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existing.data.length > 0) {
|
||||
const price = existing.data[0];
|
||||
const product = price.product as Stripe.Product;
|
||||
log(
|
||||
` ${DIM}→${RESET} ${plan.product.name} ${DIM}(déjà existant)${RESET}`
|
||||
);
|
||||
log(` ${DIM}product: ${product.id}${RESET}`);
|
||||
log(` ${DIM}price: ${price.id}${RESET}`);
|
||||
return price;
|
||||
}
|
||||
|
||||
// Sinon, crée le produit
|
||||
log(` ${CYAN}+${RESET} Création de ${plan.product.name}…`);
|
||||
const product = await stripe.products.create({
|
||||
name: plan.product.name,
|
||||
description: plan.product.description,
|
||||
metadata: {
|
||||
app: 'freedge',
|
||||
plan_key: plan.lookupKey,
|
||||
},
|
||||
});
|
||||
|
||||
// Puis le prix récurrent avec lookup_key pour la prochaine fois
|
||||
const price = await stripe.prices.create({
|
||||
product: product.id,
|
||||
unit_amount: plan.price.unitAmount,
|
||||
currency: plan.price.currency,
|
||||
recurring: { interval: plan.price.interval },
|
||||
lookup_key: plan.lookupKey,
|
||||
nickname: `${plan.product.name} — mensuel`,
|
||||
metadata: {
|
||||
app: 'freedge',
|
||||
plan_key: plan.lookupKey,
|
||||
},
|
||||
});
|
||||
|
||||
log(` ${GREEN}✓${RESET} product: ${product.id}`);
|
||||
log(` ${GREEN}✓${RESET} price: ${price.id}`);
|
||||
return price;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour (ou ajoute) une variable dans le fichier .env.
|
||||
* Préserve l'ordre et les commentaires des autres lignes.
|
||||
*/
|
||||
function upsertEnvVar(envPath: string, key: string, value: string): void {
|
||||
let content = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
content = fs.readFileSync(envPath, 'utf-8');
|
||||
}
|
||||
|
||||
const lineRegex = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (lineRegex.test(content)) {
|
||||
content = content.replace(lineRegex, `${key}=${value}`);
|
||||
} else {
|
||||
content = (content.trimEnd() + `\n${key}=${value}\n`).trimStart();
|
||||
}
|
||||
|
||||
fs.writeFileSync(envPath, content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const writeEnv = process.argv.includes('--write-env');
|
||||
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
fail(
|
||||
'STRIPE_SECRET_KEY manquante. Définis-la dans backend/.env puis relance.'
|
||||
);
|
||||
}
|
||||
if (!secretKey.startsWith('sk_')) {
|
||||
fail(`STRIPE_SECRET_KEY ne ressemble pas à une clé Stripe : ${secretKey.slice(0, 8)}…`);
|
||||
}
|
||||
|
||||
const isLive = secretKey.startsWith('sk_live_');
|
||||
log('');
|
||||
log(`${BOLD}🔧 Freedge — Initialisation Stripe${RESET}`);
|
||||
log(
|
||||
`${DIM}Mode : ${isLive ? `${RED}LIVE ⚠️${RESET}${DIM}` : `${GREEN}TEST${RESET}${DIM}`}${RESET}`
|
||||
);
|
||||
log('');
|
||||
|
||||
if (isLive) {
|
||||
log(
|
||||
`${YELLOW}⚠️ Tu utilises une clé LIVE. Les produits seront créés en production.${RESET}`
|
||||
);
|
||||
log(`${YELLOW} Appuie sur Ctrl+C dans les 5 secondes pour annuler.${RESET}`);
|
||||
log('');
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
|
||||
const stripe = new Stripe(secretKey, {
|
||||
apiVersion:
|
||||
(process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
log(`${BOLD}Plans à provisionner :${RESET}`);
|
||||
const results: Array<{ envKey: string; priceId: string }> = [];
|
||||
for (const plan of PLANS) {
|
||||
try {
|
||||
const price = await ensurePlan(stripe, plan);
|
||||
results.push({ envKey: plan.envKey, priceId: price.id });
|
||||
} catch (err) {
|
||||
log(` ${RED}✗${RESET} ${plan.product.name} : ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
log('');
|
||||
log(`${BOLD}Résultat :${RESET}`);
|
||||
log('');
|
||||
for (const { envKey, priceId } of results) {
|
||||
log(` ${CYAN}${envKey}${RESET}=${priceId}`);
|
||||
}
|
||||
log('');
|
||||
|
||||
if (writeEnv) {
|
||||
const envPath = path.resolve(process.cwd(), '.env');
|
||||
for (const { envKey, priceId } of results) {
|
||||
upsertEnvVar(envPath, envKey, priceId);
|
||||
}
|
||||
log(`${GREEN}✓${RESET} ${envPath} mis à jour.`);
|
||||
} else {
|
||||
log(
|
||||
`${DIM}Ajoute ces lignes à backend/.env (ou relance avec --write-env pour le faire automatiquement).${RESET}`
|
||||
);
|
||||
}
|
||||
|
||||
log('');
|
||||
|
||||
// Rappels utiles
|
||||
log(`${BOLD}Prochaines étapes :${RESET}`);
|
||||
log(` 1. ${DIM}Activer le Customer Portal${RESET}`);
|
||||
log(
|
||||
` ${DIM}https://dashboard.stripe.com/${isLive ? '' : 'test/'}settings/billing/portal${RESET}`
|
||||
);
|
||||
log(` 2. ${DIM}Lancer le listener webhook en local${RESET}`);
|
||||
log(` ${DIM}stripe listen --forward-to localhost:3000/stripe/webhook${RESET}`);
|
||||
log(` 3. ${DIM}Copier le whsec_ affiché dans STRIPE_WEBHOOK_SECRET${RESET}`);
|
||||
log('');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log('');
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
160
backend/scripts/sync-subscription.ts
Normal file
160
backend/scripts/sync-subscription.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Script de support : synchronise l'abonnement d'un utilisateur depuis
|
||||
* Stripe vers la base de données locale.
|
||||
*
|
||||
* À utiliser quand un webhook a été raté (par exemple `stripe listen`
|
||||
* n'était pas démarré pendant le checkout) et qu'un user payant est
|
||||
* resté bloqué en plan "gratuit" dans la DB.
|
||||
*
|
||||
* Usage :
|
||||
* npm run stripe:sync -- user@example.com
|
||||
* npm run stripe:sync -- --all # synchronise tous les users avec stripeId
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import Stripe from 'stripe';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const GREEN = '\x1b[32m';
|
||||
const RED = '\x1b[31m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const DIM = '\x1b[2m';
|
||||
const BOLD = '\x1b[1m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
function log(msg: string) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
interface PlanMapping {
|
||||
priceId: string | undefined;
|
||||
plan: 'essential' | 'premium';
|
||||
}
|
||||
|
||||
function getPlans(): PlanMapping[] {
|
||||
return [
|
||||
{ priceId: process.env.STRIPE_PRICE_ID_ESSENTIAL, plan: 'essential' },
|
||||
{ priceId: process.env.STRIPE_PRICE_ID_PREMIUM, plan: 'premium' },
|
||||
];
|
||||
}
|
||||
|
||||
async function syncUser(
|
||||
stripe: Stripe,
|
||||
prisma: PrismaClient,
|
||||
user: { id: string; email: string; stripeId: string | null }
|
||||
): Promise<{ plan: string; status: string | null } | null> {
|
||||
if (!user.stripeId) {
|
||||
log(` ${YELLOW}↷${RESET} ${user.email} ${DIM}(pas de stripeId)${RESET}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: user.stripeId,
|
||||
status: 'all',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
const active = subscriptions.data
|
||||
.filter((s) => ['active', 'trialing', 'past_due'].includes(s.status))
|
||||
.sort((a, b) => b.created - a.created)[0];
|
||||
|
||||
if (!active) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
subscription: 'free',
|
||||
subscriptionStatus: null,
|
||||
stripeSubscriptionId: null,
|
||||
subscriptionPriceId: null,
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
log(` ${DIM}−${RESET} ${user.email} ${DIM}→ free (aucun abo actif sur Stripe)${RESET}`);
|
||||
return { plan: 'free', status: null };
|
||||
}
|
||||
|
||||
const priceId = active.items.data[0]?.price.id;
|
||||
const mapping = getPlans().find((p) => p.priceId === priceId);
|
||||
const planId = mapping?.plan ?? 'essential';
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
subscription: planId,
|
||||
subscriptionStatus: active.status,
|
||||
stripeSubscriptionId: active.id,
|
||||
subscriptionPriceId: priceId ?? null,
|
||||
subscriptionCurrentPeriodEnd: new Date(active.current_period_end * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
log(
|
||||
` ${GREEN}✓${RESET} ${user.email} ${DIM}→${RESET} ${BOLD}${planId}${RESET} ${DIM}(${active.status})${RESET}`
|
||||
);
|
||||
return { plan: planId, status: active.status };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2).filter((a) => a !== '--');
|
||||
|
||||
if (args.length === 0) {
|
||||
log('Usage : npm run stripe:sync -- user@example.com');
|
||||
log(' npm run stripe:sync -- --all');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
log(`${RED}✗ STRIPE_SECRET_KEY manquante${RESET}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stripe = new Stripe(secretKey, {
|
||||
apiVersion:
|
||||
(process.env.STRIPE_API_VERSION as Stripe.StripeConfig['apiVersion']) ?? '2023-10-16',
|
||||
});
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
log('');
|
||||
log(`${BOLD}🔄 Synchronisation Stripe → DB${RESET}`);
|
||||
log('');
|
||||
|
||||
try {
|
||||
if (args.includes('--all')) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { stripeId: { not: null } },
|
||||
select: { id: true, email: true, stripeId: true },
|
||||
});
|
||||
log(`${DIM}${users.length} utilisateur(s) avec un stripeId${RESET}`);
|
||||
log('');
|
||||
for (const user of users) {
|
||||
try {
|
||||
await syncUser(stripe, prisma, user);
|
||||
} catch (err) {
|
||||
log(` ${RED}✗${RESET} ${user.email} : ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const email = args[0];
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, email: true, stripeId: true },
|
||||
});
|
||||
if (!user) {
|
||||
log(`${RED}✗ Utilisateur introuvable : ${email}${RESET}`);
|
||||
process.exit(1);
|
||||
}
|
||||
await syncUser(stripe, prisma, user);
|
||||
}
|
||||
log('');
|
||||
log(`${GREEN}✓ Terminé${RESET}`);
|
||||
} catch (err) {
|
||||
log(`${RED}✗ ${(err as Error).message}${RESET}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,10 +1,11 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { OpenAI } from 'openai';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { MultipartFile } from '@fastify/multipart';
|
||||
import { uploadFile, getFileUrl } from '../utils/storage';
|
||||
import { uploadFile, getPublicUrl } from '../utils/storage';
|
||||
import type { AudioInput, AudioSaveResult, RecipeData } from '../types/fastify';
|
||||
import {
|
||||
transcribeAudio as runTranscribe,
|
||||
@ -15,7 +16,6 @@ import type { UserPreferences } from '../ai/prompts';
|
||||
import { generateRecipeImage as runGenerateImage } from '../ai/image-generator';
|
||||
|
||||
export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
||||
// Client OpenAI partagé, avec retries et timeout configurés
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
maxRetries: Number(process.env.OPENAI_MAX_RETRIES ?? 3),
|
||||
@ -24,33 +24,35 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
||||
fastify.decorate('openai', openai);
|
||||
|
||||
// --- Transcription audio ---
|
||||
// Accepte soit un chemin local, soit un objet avec localPath.
|
||||
// On ne passe PLUS par une URL publique — les fichiers sont toujours
|
||||
// disponibles localement (sauvegardés dans ./uploads avant upload MinIO).
|
||||
fastify.decorate('transcribeAudio', async (audioInput: AudioInput): Promise<string> => {
|
||||
let tempFile: { path: string; cleanup: () => void } | null = null;
|
||||
let audioPath: string;
|
||||
|
||||
if (typeof audioInput === 'string') {
|
||||
audioPath = audioInput;
|
||||
} else if (audioInput && 'url' in audioInput && audioInput.url) {
|
||||
tempFile = await downloadToTemp(audioInput.url, '.mp3');
|
||||
audioPath = tempFile.path;
|
||||
} else if (audioInput && 'localPath' in audioInput && audioInput.localPath) {
|
||||
audioPath = audioInput.localPath;
|
||||
} else if (audioInput && 'url' in audioInput && audioInput.url) {
|
||||
// Fallback : si on reçoit une URL externe (pas notre propre API),
|
||||
// on télécharge en temp.
|
||||
const tempFile = await downloadToTemp(audioInput.url, '.webm');
|
||||
try {
|
||||
const { text } = await runTranscribe(openai, fastify.log, { audioPath: tempFile.path });
|
||||
return text;
|
||||
} finally {
|
||||
tempFile.cleanup();
|
||||
}
|
||||
} else {
|
||||
throw new Error("Format d'entrée audio non valide");
|
||||
}
|
||||
|
||||
try {
|
||||
const { text } = await runTranscribe(openai, fastify.log, { audioPath });
|
||||
return text;
|
||||
} finally {
|
||||
tempFile?.cleanup();
|
||||
}
|
||||
const { text } = await runTranscribe(openai, fastify.log, { audioPath });
|
||||
return text;
|
||||
});
|
||||
|
||||
// --- Génération de recette ---
|
||||
// Cette fonction est maintenue pour compatibilité avec le contrat existant
|
||||
// (elle retourne `RecipeData` à plat). En interne, elle utilise désormais
|
||||
// le pipeline structuré + parallélisation image/texte.
|
||||
fastify.decorate(
|
||||
'generateRecipe',
|
||||
async (
|
||||
@ -58,24 +60,19 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
||||
hint?: string,
|
||||
preferences?: unknown
|
||||
): Promise<RecipeData> => {
|
||||
// 1. Génération du texte (séquentiel obligatoire : on a besoin du titre)
|
||||
const { recipe } = await runGenerateRecipe(openai, fastify.log, {
|
||||
transcription: ingredients,
|
||||
hint,
|
||||
preferences: preferences as UserPreferences | null | undefined,
|
||||
});
|
||||
|
||||
// 2. Génération de l'image en parallèle de la sérialisation
|
||||
// (l'image n'a besoin que du titre + description, qu'on a déjà)
|
||||
const imagePromise = runGenerateImage(openai, fastify.log, {
|
||||
title: recipe.titre,
|
||||
description: recipe.description,
|
||||
});
|
||||
|
||||
// 3. Aplatir la recette structurée vers RecipeData (compatibilité Prisma)
|
||||
const flat = flattenRecipe(recipe);
|
||||
|
||||
// 4. Attendre l'image (best-effort, peut être null)
|
||||
const { url: imageUrl } = await imagePromise;
|
||||
flat.image_url = imageUrl;
|
||||
|
||||
@ -84,6 +81,10 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// --- Sauvegarde fichier audio ---
|
||||
// Stratégie : toujours sauvegarder en local D'ABORD (pour la transcription),
|
||||
// puis uploader dans MinIO en arrière-plan (pour le stockage permanent).
|
||||
// Le résultat retourne localPath pour que transcribeAudio lise le fichier
|
||||
// directement sans passer par une URL publique.
|
||||
fastify.decorate('saveAudioFile', async (file: MultipartFile): Promise<AudioSaveResult> => {
|
||||
if (!file || !file.filename) {
|
||||
throw new Error('Fichier audio invalide');
|
||||
@ -91,36 +92,38 @@ export default fp(async function aiPlugin(fastify: FastifyInstance) {
|
||||
|
||||
const fileName = `${Date.now()}-${file.filename}`;
|
||||
|
||||
// Tentative MinIO
|
||||
try {
|
||||
const filePath = await uploadFile({ filename: fileName, file: file.file }, 'audio');
|
||||
const url = await getFileUrl(filePath);
|
||||
return { success: true, url, path: filePath };
|
||||
} catch (err) {
|
||||
fastify.log.warn(`Upload MinIO échoué, fallback local: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Fallback local
|
||||
const uploadDir = './uploads';
|
||||
// 1. Toujours sauvegarder en local pour la transcription
|
||||
const uploadDir = './uploads/audio';
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
const filepath = `${uploadDir}/${fileName}`;
|
||||
const localFilePath = path.join(uploadDir, fileName);
|
||||
|
||||
if (file.file && typeof file.file.pipe === 'function') {
|
||||
await pipeline(file.file, fs.createWriteStream(filepath));
|
||||
await pipeline(file.file, fs.createWriteStream(localFilePath));
|
||||
} else {
|
||||
throw new Error('Format de fichier non pris en charge');
|
||||
}
|
||||
|
||||
return { success: true, localPath: filepath, isLocal: true };
|
||||
// 2. Upload dans MinIO en parallèle (best-effort)
|
||||
let minioPath: string | null = null;
|
||||
try {
|
||||
const fileStream = fs.createReadStream(localFilePath);
|
||||
minioPath = await uploadFile({ filename: fileName, file: fileStream }, 'audio');
|
||||
fastify.log.info(`Audio uploadé dans MinIO: ${minioPath}`);
|
||||
} catch (err) {
|
||||
fastify.log.warn(`Upload MinIO audio échoué, fichier local conservé: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
localPath: localFilePath,
|
||||
path: minioPath ?? `audio/${fileName}`,
|
||||
isLocal: true,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Convertit la recette structurée en `RecipeData` plat compatible avec
|
||||
* l'ancien contrat utilisé par les routes Prisma.
|
||||
*/
|
||||
function flattenRecipe(recipe: StructuredRecipe): RecipeData {
|
||||
const ingredients = recipe.ingredients.map((i) => {
|
||||
const note = i.notes ? ` (${i.notes})` : '';
|
||||
@ -152,7 +155,7 @@ function flattenRecipe(recipe: StructuredRecipe): RecipeData {
|
||||
portions: recipe.portions,
|
||||
difficulte: recipe.difficulte,
|
||||
conseils,
|
||||
image_url: null, // rempli plus tard
|
||||
image_url: null,
|
||||
structured: recipe,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,62 @@ import fp from 'fastify-plugin';
|
||||
import Stripe from 'stripe';
|
||||
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) {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) {
|
||||
@ -10,13 +66,15 @@ export default fp(async function stripePlugin(fastify: FastifyInstance) {
|
||||
}
|
||||
|
||||
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('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) =>
|
||||
|
||||
488
backend/src/routes/stripe.ts
Normal file
488
backend/src/routes/stripe.ts
Normal file
@ -0,0 +1,488 @@
|
||||
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/sync — Resynchronise l'abonnement depuis Stripe
|
||||
// -------------------------------------------------------------------------
|
||||
// Utile quand le webhook n'est pas arrivé (stripe listen pas démarré,
|
||||
// whsec mal configuré, etc.) : le frontend peut appeler cet endpoint
|
||||
// après un checkout pour récupérer l'état réel immédiatement.
|
||||
fastify.post(
|
||||
'/sync',
|
||||
{ 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) {
|
||||
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
|
||||
}
|
||||
if (!user.stripeId) {
|
||||
return reply.code(400).send({
|
||||
error: 'Aucun customer Stripe pour cet utilisateur',
|
||||
});
|
||||
}
|
||||
|
||||
// Cherche l'abonnement actif (status active ou trialing) du customer
|
||||
const subscriptions = await fastify.stripe.subscriptions.list({
|
||||
customer: user.stripeId,
|
||||
status: 'all',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Trie par date de création desc pour prendre le plus récent
|
||||
const active = subscriptions.data
|
||||
.filter((s) => ['active', 'trialing', 'past_due'].includes(s.status))
|
||||
.sort((a, b) => b.created - a.created)[0];
|
||||
|
||||
if (!active) {
|
||||
// Pas d'abonnement actif → repasse en plan gratuit
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
subscription: 'free',
|
||||
subscriptionStatus: null,
|
||||
stripeSubscriptionId: null,
|
||||
subscriptionPriceId: null,
|
||||
subscriptionCurrentPeriodEnd: null,
|
||||
},
|
||||
});
|
||||
return { plan: 'free', status: 'none', synced: true };
|
||||
}
|
||||
|
||||
await applySubscriptionToUser(fastify, active, user.id);
|
||||
|
||||
// Relit l'utilisateur après update
|
||||
const refreshed = await fastify.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
subscription: true,
|
||||
subscriptionStatus: true,
|
||||
subscriptionCurrentPeriodEnd: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
plan: refreshed?.subscription ?? 'free',
|
||||
status: refreshed?.subscriptionStatus ?? null,
|
||||
currentPeriodEnd: refreshed?.subscriptionCurrentPeriodEnd ?? null,
|
||||
synced: true,
|
||||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(err, 'stripe sync failed');
|
||||
return reply.code(500).send({ error: 'Erreur lors de la synchronisation' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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/portal/cancel — Portail deep-linké sur le flow d'annulation
|
||||
// -------------------------------------------------------------------------
|
||||
// Contrairement à /portal qui ouvre le portail en mode "libre", celui-ci
|
||||
// ouvre directement l'écran de confirmation d'annulation pour la
|
||||
// subscription active de l'utilisateur, et redirige automatiquement
|
||||
// vers /subscription/cancelled après validation.
|
||||
fastify.post(
|
||||
'/portal/cancel',
|
||||
{ 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 Stripe associé',
|
||||
});
|
||||
}
|
||||
if (!user.stripeSubscriptionId) {
|
||||
return reply.code(400).send({
|
||||
error: 'Aucun abonnement actif à annuler',
|
||||
});
|
||||
}
|
||||
|
||||
const appUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
const portalSession = await fastify.stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeId,
|
||||
// Return URL pour le bouton "retour" classique du portail
|
||||
return_url: `${appUrl}/profile`,
|
||||
// Deep-link dans le flow d'annulation avec auto-redirect
|
||||
flow_data: {
|
||||
type: 'subscription_cancel',
|
||||
subscription_cancel: {
|
||||
subscription: user.stripeSubscriptionId,
|
||||
},
|
||||
after_completion: {
|
||||
type: 'redirect',
|
||||
redirect: {
|
||||
return_url: `${appUrl}/subscription/cancelled`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { url: portalSession.url };
|
||||
} catch (err) {
|
||||
fastify.log.error(err, 'cancel portal session creation failed');
|
||||
return reply.code(500).send({ error: "Erreur lors de l'ouverture 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;
|
||||
@ -9,6 +9,7 @@ import rateLimit from '@fastify/rate-limit';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import authPlugin from './plugins/auth';
|
||||
import stripePlugin from './plugins/stripe';
|
||||
@ -18,6 +19,9 @@ import googleAuthPlugin from './plugins/google-auth';
|
||||
import authRoutes from './routes/auth';
|
||||
import recipesRoutes from './routes/recipes';
|
||||
import usersRoutes from './routes/users';
|
||||
import stripeRoutes from './routes/stripe';
|
||||
|
||||
import { getFile, isMinioConfigured, ensureBucket } from './utils/storage';
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
@ -26,22 +30,59 @@ const fastify = Fastify({
|
||||
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).
|
||||
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();
|
||||
fastify.decorate('prisma', prisma);
|
||||
|
||||
/** Devine le Content-Type à partir de l'extension. */
|
||||
function mimeFromExt(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.webm': 'audio/webm',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.wav': 'audio/wav',
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
// --- Sécurité ---
|
||||
await fastify.register(helmet, {
|
||||
contentSecurityPolicy: false,
|
||||
// Autorise le frontend (cross-origin) à charger les images/audio servis
|
||||
// depuis /uploads/*. Sans ça, le navigateur bloque avec
|
||||
// ERR_BLOCKED_BY_RESPONSE.NotSameOrigin.
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
});
|
||||
|
||||
await fastify.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
skip: (req) => req.url === '/stripe/webhook',
|
||||
});
|
||||
|
||||
const allowedOrigins = (
|
||||
@ -62,11 +103,37 @@ async function bootstrap(): Promise<void> {
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// --- Fichiers statiques (fallback local pour images/audio) ---
|
||||
await fastify.register(fastifyStatic, {
|
||||
root: path.resolve('./uploads'),
|
||||
prefix: '/uploads/',
|
||||
decorateReply: false,
|
||||
// --- Route /uploads/* : sert les fichiers depuis MinIO, fallback local ---
|
||||
fastify.get('/uploads/*', async (request, reply) => {
|
||||
// Le wildcard capture tout après /uploads/
|
||||
const filePath = (request.params as { '*': string })['*'];
|
||||
if (!filePath) {
|
||||
return reply.code(400).send({ error: 'Chemin de fichier requis' });
|
||||
}
|
||||
|
||||
const contentType = mimeFromExt(filePath);
|
||||
|
||||
// 1. Essayer MinIO d'abord
|
||||
if (isMinioConfigured()) {
|
||||
try {
|
||||
const stream = await getFile(filePath);
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
return reply.send(stream);
|
||||
} catch (err) {
|
||||
fastify.log.debug(`MinIO miss pour ${filePath}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback : fichier local dans ./uploads/
|
||||
const localPath = path.resolve('./uploads', filePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
return reply.send(fs.createReadStream(localPath));
|
||||
}
|
||||
|
||||
return reply.code(404).send({ error: 'Fichier non trouvé' });
|
||||
});
|
||||
|
||||
// --- Plugins applicatifs ---
|
||||
@ -79,9 +146,20 @@ async function bootstrap(): Promise<void> {
|
||||
await fastify.register(authRoutes, { prefix: '/auth' });
|
||||
await fastify.register(recipesRoutes, { prefix: '/recipes' });
|
||||
await fastify.register(usersRoutes, { prefix: '/users' });
|
||||
await fastify.register(stripeRoutes, { prefix: '/stripe' });
|
||||
|
||||
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
|
||||
|
||||
// --- S'assurer que le bucket MinIO existe au démarrage ---
|
||||
if (isMinioConfigured()) {
|
||||
try {
|
||||
await ensureBucket();
|
||||
fastify.log.info('MinIO bucket prêt');
|
||||
} catch (err) {
|
||||
fastify.log.warn(`MinIO bucket init échoué: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
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 {
|
||||
titre: string;
|
||||
ingredients: string | string[];
|
||||
|
||||
@ -5,6 +5,9 @@ const REQUIRED = ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY'] as const;
|
||||
|
||||
const OPTIONAL_WARN = [
|
||||
'STRIPE_SECRET_KEY',
|
||||
'STRIPE_WEBHOOK_SECRET',
|
||||
'STRIPE_PRICE_ID_ESSENTIAL',
|
||||
'STRIPE_PRICE_ID_PREMIUM',
|
||||
'MINIO_ENDPOINT',
|
||||
'MINIO_PORT',
|
||||
'MINIO_ACCESS_KEY',
|
||||
|
||||
@ -28,9 +28,6 @@ function getClient(): Minio.Client | null {
|
||||
pathStyle: true,
|
||||
};
|
||||
|
||||
// Ne désactiver la vérification TLS que si explicitement demandé.
|
||||
// `transport` n'expose pas `agent` dans les types récents de minio ; on
|
||||
// passe par un cast ciblé car le runtime minio accepte toujours cette option.
|
||||
if (useSSL && allowSelfSigned) {
|
||||
(clientOpts as unknown as { transport: unknown }).transport = {
|
||||
agent: new https.Agent({ rejectUnauthorized: false }),
|
||||
@ -47,6 +44,21 @@ function bucket(): string {
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Vérifie que le bucket existe, sinon le crée. */
|
||||
export async function ensureBucket(): Promise<void> {
|
||||
const client = getClient();
|
||||
if (!client) return;
|
||||
const exists = await client.bucketExists(bucket());
|
||||
if (!exists) {
|
||||
await client.makeBucket(bucket());
|
||||
}
|
||||
}
|
||||
|
||||
/** Retourne true si MinIO est configuré et joignable. */
|
||||
export function isMinioConfigured(): boolean {
|
||||
return getClient() !== null;
|
||||
}
|
||||
|
||||
export async function uploadFile(file: UploadableFile, folderPath: string): Promise<string> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error('MinIO non configuré');
|
||||
@ -54,6 +66,7 @@ export async function uploadFile(file: UploadableFile, folderPath: string): Prom
|
||||
const fileName = `${Date.now()}-${file.filename}`;
|
||||
const filePath = `${folderPath}/${fileName}`;
|
||||
await client.putObject(bucket(), filePath, file.file);
|
||||
// Retourne le chemin MinIO (pas d'URL pré-signée)
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@ -75,8 +88,16 @@ export async function listFiles(folderPath: string) {
|
||||
return client.listObjects(bucket(), folderPath);
|
||||
}
|
||||
|
||||
export async function getFileUrl(filePath: string): Promise<string> {
|
||||
const client = getClient();
|
||||
if (!client) throw new Error('MinIO non configuré');
|
||||
return client.presignedUrl('GET', bucket(), filePath);
|
||||
/**
|
||||
* Construit l'URL publique pour un fichier stocké dans MinIO.
|
||||
* L'URL passe par la route API /uploads/* du backend qui proxifie MinIO.
|
||||
*/
|
||||
export function getPublicUrl(filePath: string): string {
|
||||
const base = process.env.PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
return `${base}/uploads/${filePath}`;
|
||||
}
|
||||
|
||||
// Compat : ancien nom, maintenant retourne l'URL publique (pas pré-signée)
|
||||
export async function getFileUrl(filePath: string): Promise<string> {
|
||||
return getPublicUrl(filePath);
|
||||
}
|
||||
|
||||
64
docker-compose.prod.yml
Normal file
64
docker-compose.prod.yml
Normal file
@ -0,0 +1,64 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: freedge-backend
|
||||
restart: always
|
||||
env_file: ./backend/.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DATABASE_URL=file:/app/data/freedge.db
|
||||
- CORS_ORIGINS=https://freedge.app
|
||||
- FRONTEND_URL=https://freedge.app
|
||||
- PUBLIC_BASE_URL=https://freedge.app/api
|
||||
- MINIO_ENDPOINT=host.docker.internal
|
||||
- MINIO_PORT=9000
|
||||
- MINIO_USE_SSL=false
|
||||
- MINIO_ACCESS_KEY=admin
|
||||
- MINIO_SECRET_KEY=Kx9mP2vL7wQn4jRs
|
||||
- MINIO_BUCKET=freedge
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- db-data:/app/data
|
||||
- uploads:/app/uploads
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
- freedge
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_BASE_URL: https://freedge.app/api
|
||||
VITE_GOOGLE_CLIENT_ID: 173866668387-i18igc0e1avqtsaqq6nig898bv6pvuk6.apps.googleusercontent.com
|
||||
container_name: freedge-frontend
|
||||
restart: always
|
||||
expose:
|
||||
- "80"
|
||||
networks:
|
||||
- freedge
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: freedge-nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./nginx-prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
networks:
|
||||
- freedge
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
uploads:
|
||||
|
||||
networks:
|
||||
freedge:
|
||||
driver: bridge
|
||||
53
frontend/Dockerfile
Normal file
53
frontend/Dockerfile
Normal file
@ -0,0 +1,53 @@
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ARG VITE_GOOGLE_CLIENT_ID
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||
|
||||
RUN pnpm run build-no-error
|
||||
RUN chmod -R a+r /app/dist && find /app/dist -type d -exec chmod a+rx {} \;
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
RUN printf 'server {\n\
|
||||
listen 80;\n\
|
||||
server_name _;\n\
|
||||
root /usr/share/nginx/html;\n\
|
||||
index index.html;\n\
|
||||
\n\
|
||||
# index.html : jamais mis en cache, toujours revalidé\n\
|
||||
location = /index.html {\n\
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";\n\
|
||||
add_header Pragma "no-cache";\n\
|
||||
add_header Expires "0";\n\
|
||||
}\n\
|
||||
\n\
|
||||
# SPA fallback — mêmes headers anti-cache pour le HTML\n\
|
||||
location / {\n\
|
||||
try_files $uri $uri/ /index.html;\n\
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";\n\
|
||||
add_header Pragma "no-cache";\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Assets hashés par Vite (ex: index-abc123.js) — cache agressif OK\n\
|
||||
location /assets/ {\n\
|
||||
expires 1y;\n\
|
||||
add_header Cache-Control "public, immutable";\n\
|
||||
}\n\
|
||||
\n\
|
||||
# Autres fichiers statiques (favicon, images, fonts)\n\
|
||||
location ~* \\.(png|jpg|jpeg|gif|ico|svg|woff2|webp)$ {\n\
|
||||
expires 7d;\n\
|
||||
add_header Cache-Control "public";\n\
|
||||
}\n\
|
||||
}\n' > /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@ -8,6 +8,9 @@ import RecipeForm from '@/pages/Recipes/RecipeForm'
|
||||
import Profile from './pages/Profile'
|
||||
import Home from './pages/Home'
|
||||
import ResetPassword from '@/pages/ResetPassword'
|
||||
import Pricing from '@/pages/Pricing'
|
||||
import CheckoutSuccess from '@/pages/CheckoutSuccess'
|
||||
import SubscriptionCancelled from '@/pages/SubscriptionCancelled'
|
||||
import { MainLayout } from './layouts/MainLayout'
|
||||
import useAuth from '@/hooks/useAuth'
|
||||
import { ProtectedRoute, AuthRoute } from '@/components/RouteGuards'
|
||||
@ -40,6 +43,11 @@ function App() {
|
||||
{/* Profil */}
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
|
||||
{/* Abonnement */}
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/checkout/success" element={<ProtectedRoute><CheckoutSuccess /></ProtectedRoute>} />
|
||||
<Route path="/subscription/cancelled" element={<ProtectedRoute><SubscriptionCancelled /></ProtectedRoute>} />
|
||||
|
||||
{/* Racine */}
|
||||
<Route path="/" element={isAuthenticated ? <Navigate to="/recipes" replace /> : <Home />} />
|
||||
|
||||
|
||||
63
frontend/src/api/stripe.ts
Normal file
63
frontend/src/api/stripe.ts
Normal file
@ -0,0 +1,63 @@
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ouvre directement le flow d'annulation du Customer Portal.
|
||||
* Après validation, Stripe redirige automatiquement vers
|
||||
* /subscription/cancelled (géré par flow_data.after_completion côté backend).
|
||||
*/
|
||||
openCancelFlow: async (): Promise<void> => {
|
||||
const { url } = await apiService.post<{ url: string }>("/stripe/portal/cancel", {});
|
||||
if (!url) throw new Error("Pas d'URL de portail reçue");
|
||||
window.location.href = url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resynchronise l'abonnement depuis Stripe. Utile quand le webhook
|
||||
* n'est pas arrivé (mode dev sans `stripe listen`, latence, etc.).
|
||||
*/
|
||||
syncSubscription: (): Promise<SubscriptionStatus & { synced: boolean }> =>
|
||||
apiService.post("/stripe/sync", {}),
|
||||
};
|
||||
|
||||
export default stripeService;
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
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 { motion, AnimatePresence } from "framer-motion"
|
||||
import { Logo } from "@/components/illustrations/Logo"
|
||||
@ -40,8 +40,7 @@ export function Header() {
|
||||
const navItems = [
|
||||
{ name: "Accueil", path: "/", icon: Home, public: true },
|
||||
{ name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
|
||||
// { name: "Mes recettes", path: "/recipes", icon: BookOpen, public: false },
|
||||
// { name: "Favoris", path: "/favorites", icon: Heart, public: false },
|
||||
{ name: "Tarifs", path: "/pricing", icon: Sparkles, public: true },
|
||||
{ name: "Profil", path: "/profile", icon: User, public: false },
|
||||
]
|
||||
|
||||
|
||||
@ -1,23 +1,84 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import vmsg from "vmsg";
|
||||
|
||||
const recorder = new vmsg.Recorder({
|
||||
wasmURL: "https://unpkg.com/vmsg@0.3.0/vmsg.wasm"
|
||||
});
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook d'enregistrement audio utilisant l'API native MediaRecorder.
|
||||
*
|
||||
* Produit un fichier WebM/Opus (ou MP4/AAC sur Safari) directement supporté
|
||||
* par l'API OpenAI Whisper — sans dépendance WASM externe.
|
||||
*/
|
||||
export function useAudioRecorder() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordings, setRecordings] = useState<string[]>([]);
|
||||
const [currentRecording, setCurrentRecording] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
/** Choisit le meilleur mimeType supporté par le navigateur. */
|
||||
const getMimeType = useCallback(() => {
|
||||
const types = [
|
||||
"audio/webm;codecs=opus",
|
||||
"audio/webm",
|
||||
"audio/mp4",
|
||||
"audio/ogg;codecs=opus",
|
||||
];
|
||||
for (const type of types) {
|
||||
if (MediaRecorder.isTypeSupported(type)) return type;
|
||||
}
|
||||
return ""; // fallback : le navigateur choisira
|
||||
}, []);
|
||||
|
||||
/** Extension correspondant au mimeType. */
|
||||
const getExtension = useCallback((mime: string) => {
|
||||
if (mime.includes("webm")) return "webm";
|
||||
if (mime.includes("mp4")) return "m4a";
|
||||
if (mime.includes("ogg")) return "ogg";
|
||||
return "webm";
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Nécessaire sur mobile : initAudio DOIT être dans un handler utilisateur (tap/click)
|
||||
await recorder.initAudio();
|
||||
await recorder.initWorker();
|
||||
await recorder.startRecording();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
sampleRate: 44100,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
|
||||
const mimeType = getMimeType();
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType: mimeType || undefined,
|
||||
audioBitsPerSecond: 128000,
|
||||
});
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const actualMime = recorder.mimeType || mimeType || "audio/webm";
|
||||
const blob = new Blob(chunksRef.current, { type: actualMime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setRecordings((prev) => [...prev, url]);
|
||||
setCurrentRecording(url);
|
||||
|
||||
// Arrête les pistes micro
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
mediaRecorderRef.current = recorder;
|
||||
// timeslice de 250ms pour avoir des chunks réguliers
|
||||
recorder.start(250);
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du démarrage de l'enregistrement :", error);
|
||||
@ -25,28 +86,32 @@ export function useAudioRecorder() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [getMimeType]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (!isRecording) return;
|
||||
if (!isRecording || !mediaRecorderRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const blob = await recorder.stopRecording();
|
||||
const url = URL.createObjectURL(blob);
|
||||
return new Promise<{ blob: Blob; url: string }>((resolve) => {
|
||||
const recorder = mediaRecorderRef.current!;
|
||||
const mimeType = recorder.mimeType || getMimeType() || "audio/webm";
|
||||
const ext = getExtension(mimeType);
|
||||
|
||||
setRecordings(prev => [...prev, url]);
|
||||
setCurrentRecording(url);
|
||||
const originalOnStop = recorder.onstop;
|
||||
recorder.onstop = (ev) => {
|
||||
// Appelle le handler de base (qui crée le blob + currentRecording)
|
||||
if (originalOnStop) originalOnStop.call(recorder, ev);
|
||||
|
||||
return { blob, url };
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'arrêt de l'enregistrement :", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsRecording(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isRecording]);
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setIsRecording(false);
|
||||
setIsLoading(false);
|
||||
resolve({ blob, url });
|
||||
};
|
||||
|
||||
recorder.stop();
|
||||
});
|
||||
}, [isRecording, getMimeType, getExtension]);
|
||||
|
||||
const toggleRecording = useCallback(async () => {
|
||||
if (isRecording) {
|
||||
@ -57,14 +122,18 @@ export function useAudioRecorder() {
|
||||
}, [isRecording, startRecording, stopRecording]);
|
||||
|
||||
const clearRecordings = useCallback(() => {
|
||||
recordings.forEach(url => URL.revokeObjectURL(url));
|
||||
recordings.forEach((url) => URL.revokeObjectURL(url));
|
||||
setRecordings([]);
|
||||
setCurrentRecording(null);
|
||||
}, [recordings]);
|
||||
|
||||
// Cleanup au démontage
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recordings.forEach(url => URL.revokeObjectURL(url));
|
||||
recordings.forEach((url) => URL.revokeObjectURL(url));
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
}
|
||||
};
|
||||
}, [recordings]);
|
||||
|
||||
@ -76,6 +145,6 @@ export function useAudioRecorder() {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
toggleRecording,
|
||||
clearRecordings
|
||||
clearRecordings,
|
||||
};
|
||||
}
|
||||
|
||||
180
frontend/src/pages/CheckoutSuccess.tsx
Normal file
180
frontend/src/pages/CheckoutSuccess.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
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)
|
||||
|
||||
// Stratégie en 2 temps :
|
||||
// 1. Essaye d'abord un sync direct depuis Stripe (instantané, fiable)
|
||||
// 2. Si ça échoue, poll /stripe/subscription jusqu'à ce que le webhook
|
||||
// ait activé l'abo (max 10 × 1.5s)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const hydrate = async () => {
|
||||
// Tentative #1 : sync immédiat
|
||||
try {
|
||||
const synced = await stripeService.syncSubscription()
|
||||
if (cancelled) return
|
||||
if (synced.hasActiveSubscription || synced.plan !== "free") {
|
||||
setSubscription(synced)
|
||||
setPolling(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* on tombe dans le polling */
|
||||
}
|
||||
|
||||
// Tentative #2 : polling (si le sync n'a rien trouvé, ce qui peut
|
||||
// arriver si Stripe n'a pas encore flushé la subscription)
|
||||
let attempts = 0
|
||||
const maxAttempts = 10
|
||||
const interval = setInterval(async () => {
|
||||
attempts++
|
||||
try {
|
||||
const sub = await stripeService.getSubscription()
|
||||
if (cancelled) {
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
setSubscription(sub)
|
||||
if (sub.hasActiveSubscription || attempts >= maxAttempts) {
|
||||
clearInterval(interval)
|
||||
setPolling(false)
|
||||
}
|
||||
} catch {
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(interval)
|
||||
setPolling(false)
|
||||
}
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
hydrate()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -90,7 +90,10 @@ export default function RecipeForm() {
|
||||
fetch(currentRecording)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const file = new File([blob], "recording.mp3", { type: "audio/mp3" })
|
||||
// Détecte le format réel du blob (webm, mp4, ogg…)
|
||||
const mime = blob.type || "audio/webm"
|
||||
const ext = mime.includes("mp4") ? "m4a" : mime.includes("ogg") ? "ogg" : "webm"
|
||||
const file = new File([blob], `recording.${ext}`, { type: mime })
|
||||
setAudioFile(file)
|
||||
setPageState("review")
|
||||
setError("")
|
||||
@ -140,6 +143,10 @@ export default function RecipeForm() {
|
||||
|
||||
const handleStopRecording = async () => {
|
||||
if (!isRecording) return
|
||||
if (recordingTime < 2) {
|
||||
setError("Enregistre au moins 2 secondes pour que le chef puisse t'écouter.")
|
||||
return
|
||||
}
|
||||
await stopRecording()
|
||||
// Le useEffect sur currentRecording bascule vers 'review'
|
||||
}
|
||||
@ -153,6 +160,10 @@ export default function RecipeForm() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!audioFile) return
|
||||
if (audioFile.size < 5000) {
|
||||
setError("L'enregistrement est trop court. Réessaie en parlant un peu plus longtemps.")
|
||||
return
|
||||
}
|
||||
|
||||
setPageState("processing")
|
||||
setError("")
|
||||
@ -593,16 +604,14 @@ export default function RecipeForm() {
|
||||
<div className="w-full max-w-md mt-6 flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="flex-1 rounded-full"
|
||||
className="flex-1 h-14 rounded-full text-base font-medium"
|
||||
onClick={handleResetRecording}
|
||||
>
|
||||
Recommencer
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
className="flex-1 rounded-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-lg shadow-orange-500/25 hover:shadow-orange-500/40 transition-all group"
|
||||
className="flex-1 h-14 rounded-full text-base font-semibold bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-lg shadow-orange-500/25 hover:shadow-xl hover:shadow-orange-500/40 transition-all group"
|
||||
>
|
||||
<ChefHat className="mr-2 h-5 w-5 transition-transform group-hover:rotate-[-8deg]" />
|
||||
Créer ma recette
|
||||
|
||||
136
frontend/src/pages/SubscriptionCancelled.tsx
Normal file
136
frontend/src/pages/SubscriptionCancelled.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { motion } from "framer-motion"
|
||||
import { Heart, ArrowRight, ChefHat, Sparkles } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import stripeService, { type SubscriptionStatus } from "@/api/stripe"
|
||||
|
||||
export default function SubscriptionCancelled() {
|
||||
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null)
|
||||
|
||||
// Sync immédiat avec Stripe pour refléter l'annulation dans la DB
|
||||
useEffect(() => {
|
||||
stripeService.syncSubscription().then(setSubscription).catch(() => {
|
||||
// Fallback sur un simple GET si le sync échoue
|
||||
stripeService.getSubscription().then(setSubscription).catch(() => {/* ignore */})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const periodEnd = subscription?.currentPeriodEnd
|
||||
? new Date(subscription.currentPeriodEnd).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 md:py-20 text-center">
|
||||
{/* Icône heart animée */}
|
||||
<motion.div
|
||||
className="relative mx-auto mb-8 flex h-28 w-28 items-center justify-center"
|
||||
initial={{ scale: 0, rotate: -20 }}
|
||||
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-orange-200/60 to-amber-200/40 blur-2xl"
|
||||
animate={{ scale: [1, 1.15, 1], opacity: [0.6, 1, 0.6] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
/>
|
||||
<div className="relative z-10 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-orange-400 to-amber-500 text-white shadow-2xl shadow-orange-500/30 ring-4 ring-white dark:ring-slate-900">
|
||||
<Heart className="h-12 w-12 fill-current" strokeWidth={1.5} />
|
||||
</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-3">
|
||||
À bientôt, on l'espère
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-base md:text-lg max-w-md mx-auto">
|
||||
Ton abonnement a bien été annulé. Merci d'avoir fait un bout de chemin
|
||||
avec Freedge.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Info période payée */}
|
||||
{periodEnd && (
|
||||
<motion.div
|
||||
className="mt-8 mx-auto max-w-md rounded-2xl border border-orange-200/60 bg-orange-50/60 dark:bg-orange-950/20 dark:border-orange-900/40 p-5 backdrop-blur-sm"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 shrink-0 rounded-full bg-orange-100 dark:bg-orange-950/60 flex items-center justify-center">
|
||||
<Sparkles className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Profite de ton plan jusqu'au {periodEnd}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Tu conserves l'accès à toutes les fonctionnalités premium jusqu'à
|
||||
la fin de la période déjà payée. Ensuite, tu repasseras
|
||||
automatiquement sur le plan gratuit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Message de re-engagement */}
|
||||
<motion.p
|
||||
className="mt-8 text-sm text-muted-foreground max-w-md mx-auto italic"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
Tu changes d'avis ? Tu peux te réabonner à tout moment depuis la page
|
||||
Tarifs, sans perdre tes recettes ni tes préférences.
|
||||
</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.8 }}
|
||||
>
|
||||
<Link to="/recipes">
|
||||
<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" />
|
||||
Retour à mes recettes
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/pricing">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto h-12 px-6 rounded-full"
|
||||
>
|
||||
Voir les plans
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-12 text-xs text-muted-foreground"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
>
|
||||
Une dernière chose — si tu as un retour à nous partager, n'hésite pas :
|
||||
ça nous aide à faire mieux.
|
||||
</motion.p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
k8s/configmap.yml
Normal file
42
k8s/configmap.yml
Normal file
@ -0,0 +1,42 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: freedge-config
|
||||
namespace: freedge
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
PORT: "3000"
|
||||
LOG_LEVEL: "info"
|
||||
CORS_ORIGINS: "https://freedge.app"
|
||||
FRONTEND_URL: "https://freedge.app"
|
||||
PUBLIC_BASE_URL: "https://freedge.app/api"
|
||||
OPENAI_TEXT_MODEL: "gpt-4o-mini"
|
||||
OPENAI_TRANSCRIBE_MODEL: "gpt-4o-mini-transcribe"
|
||||
ENABLE_IMAGE_GENERATION: "true"
|
||||
OPENAI_IMAGE_MODEL: "gpt-image-1"
|
||||
OPENAI_IMAGE_QUALITY: "medium"
|
||||
OPENAI_IMAGE_SIZE: "1024x1024"
|
||||
OPENAI_MAX_RETRIES: "3"
|
||||
OPENAI_TIMEOUT_MS: "60000"
|
||||
proxy.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*) /$1 break;
|
||||
proxy_pass http://freedge-backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://freedge-frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
122
k8s/deployment.yml
Normal file
122
k8s/deployment.yml
Normal file
@ -0,0 +1,122 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: freedge-backend
|
||||
namespace: freedge
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: freedge-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: freedge-backend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry-secret
|
||||
initContainers:
|
||||
- name: prisma-db-push
|
||||
image: git.arthurbarre.fr/ordinarthur/freedge-backend:latest
|
||||
command: ["sh", "-c", "npx prisma db push --skip-generate"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: freedge-config
|
||||
- secretRef:
|
||||
name: freedge-secrets
|
||||
volumeMounts:
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
containers:
|
||||
- name: freedge-backend
|
||||
image: git.arthurbarre.fr/ordinarthur/freedge-backend:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: freedge-config
|
||||
- secretRef:
|
||||
name: freedge-secrets
|
||||
volumeMounts:
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
volumes:
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: freedge-uploads
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: freedge-frontend
|
||||
namespace: freedge
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: freedge-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: freedge-frontend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry-secret
|
||||
containers:
|
||||
- name: freedge-frontend
|
||||
image: git.arthurbarre.fr/ordinarthur/freedge-frontend:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: freedge-proxy
|
||||
namespace: freedge
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: freedge-proxy
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: freedge-proxy
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- containerPort: 80
|
||||
volumeMounts:
|
||||
- name: proxy-config
|
||||
mountPath: /etc/nginx/conf.d/default.conf
|
||||
subPath: proxy.conf
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: proxy-config
|
||||
configMap:
|
||||
name: freedge-config
|
||||
4
k8s/namespace.yml
Normal file
4
k8s/namespace.yml
Normal file
@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: freedge
|
||||
12
k8s/pvc.yml
Normal file
12
k8s/pvc.yml
Normal file
@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: freedge-uploads
|
||||
namespace: freedge
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
40
k8s/service.yml
Normal file
40
k8s/service.yml
Normal file
@ -0,0 +1,40 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: freedge-backend
|
||||
namespace: freedge
|
||||
spec:
|
||||
selector:
|
||||
app: freedge-backend
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: freedge-frontend
|
||||
namespace: freedge
|
||||
spec:
|
||||
selector:
|
||||
app: freedge-frontend
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: freedge-proxy
|
||||
namespace: freedge
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: freedge-proxy
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30082
|
||||
23
nginx-prod.conf
Normal file
23
nginx-prod.conf
Normal file
@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
# /api/* → backend Fastify (strip /api prefix)
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*) /$1 break;
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
# Tout le reste → frontend React (SPA)
|
||||
location / {
|
||||
proxy_pass http://frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user