Compare commits

...

10 Commits

Author SHA1 Message Date
ordinarthur
21c92abc9c feat: implement Kubernetes deployment infrastructure, migrate database to PostgreSQL, and add CI/CD pipeline
Some checks failed
Build & Deploy to K3s / build-and-deploy (push) Failing after 24s
2026-04-11 14:09:16 +02:00
3bff3c8600 feat: MinIO storage, native audio recording, production Docker config
- Replace vmsg WASM encoder with native MediaRecorder API (WebM/Opus)
  to fix empty MP3 files causing OpenAI Whisper 400 errors
- Add minimum recording duration (2s) and file size (5KB) guards
- Add MinIO S3 storage integration for recipe images and audio
- Add /uploads/* API route that proxies files from MinIO with local fallback
- Save audio locally first for transcription, then upload to MinIO
  (fixes ECONNREFUSED when backend tried to fetch its own public URL)
- Add docker-compose.prod.yml, nginx-prod.conf, frontend Dockerfile
- Frontend Dockerfile: no-cache headers on index.html, long cache on hashed assets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 12:01:26 +02:00
ordinarthur
1b3f53c086 fix(profile): force Stripe sync on mount + on window focus
Problem reported: user cancelled their subscription via the generic
'Gérer' portal, but the backend still showed the old subscription.
Root cause: stripe listen wasn't running, so the
customer.subscription.updated webhook never reached the server. The
Profile page was only reading from the DB (getSubscription), so it
stayed stale forever.

Fix: call stripeService.syncSubscription() alongside getSubscription()
at mount time. The fast DB read still happens first (instant display),
then the Stripe API call updates the state if anything has drifted.
Also add a window.addEventListener('focus', ...) listener that re-syncs
every time the user tabs back to the app — handles the common pattern
of opening Stripe portal in a new tab, doing something, then coming
back.

This makes the Profile self-healing even without a webhook setup in dev.
Production should still run the webhook for other apps/users, but this
fallback ensures individual users see the truth on their next visit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:18:12 +02:00
ordinarthur
b783627890 feat(stripe): deep-linked cancel flow with auto-redirect
Before: 'Gérer l'abonnement' opened the generic Customer Portal. If the
user cancelled, the portal's 'return_url' was just a button label —
nothing auto-redirected back to Freedge, so the user was stranded on
billing.stripe.com after clicking 'Cancel'.

Now: dedicated 'Annuler' button on the Profile SubscriptionCard that
calls a new backend endpoint POST /stripe/portal/cancel. This creates
a portal session with flow_data.type = 'subscription_cancel' deep-linked
to the user's active subscription, plus after_completion.type = 'redirect'
so Stripe automatically redirects to /subscription/cancelled when the
cancellation is confirmed.

New page /subscription/cancelled:
- Animated heart badge (spring + pulsing halo)
- 'À bientôt, on l'espère' title
- Info box showing the period-end date (fetched via sync on mount)
  so the user knows they still have access until the end of the
  already-paid period
- Re-engagement message + 'Retour aux recettes' / 'Voir les plans' CTAs
- On mount: calls /stripe/sync so the DB is updated immediately
  (doesn't wait for the customer.subscription.updated webhook)

Profile SubscriptionCard paid-state footer now has two buttons side by
side: 'Gérer' (outline) and 'Annuler' (ghost with red hover).

Backend verified: Stripe SDK v12 supports flow_data.after_completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:10:57 +02:00
ordinarthur
b0e9425ed5 feat(stripe): on-demand sync fallback when webhooks are missed
Problem: if stripe listen is not running (dev) or the webhook secret is
misconfigured, a successful checkout leaves the user stuck on the free
plan in the DB even though Stripe knows they're subscribed.

Solution: 3 recovery mechanisms.

1. Backend: POST /stripe/sync (auth required)
   Fetches the current user's subscriptions from Stripe by customer ID,
   picks the most recent active/trialing/past_due one, and applies it to
   the User row via the same applySubscriptionToUser helper used by the
   webhook. If no active sub exists, downgrades to free. Returns the
   current plan state.

2. Frontend: CheckoutSuccess now calls /stripe/sync first (instant,
   reliable) before falling back to polling /stripe/subscription. This
   fixes the 'just paid but still free' bug even with no webhook setup.

3. Frontend: 'Rafraîchir' button on the Profile free-plan upgrade banner
   (ghost style with RefreshCw spinning icon). Tooltip hints at its
   purpose. Users who paid but see the free state can click it to
   self-heal in one click.

4. Backend script: scripts/sync-subscription.ts
   - npm run stripe:sync -- user@example.com  (sync one user by email)
   - npm run stripe:sync -- --all             (sync every user with a
                                                stripeId, useful after
                                                a prod webhook outage)
   Colored output with ✓ / ✗ / ↷ status per user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:07:02 +02:00
ordinarthur
8d1202ca34 fix(profile): remove nested Checkbox inside equipment tile buttons
Radix Checkbox renders a <button>, so wrapping it inside our own tile
<button> violated HTML nesting rules and blocked the click from reaching
the tile handler. The visual state (orange border + bg) already
indicates selection — drop the Checkbox entirely and add aria-pressed
for accessibility. Also bump vertical padding and add an active scale
for tactile feedback.
2026-04-08 14:02:27 +02:00
ordinarthur
64d2bf4506 feat(stripe): idempotent setup script that provisions products + prices
New script backend/scripts/setup-stripe.ts that:
- Reads STRIPE_SECRET_KEY from .env
- Detects test vs live mode and warns + 5s delay for live
- For each plan (Essentiel 3EUR/mo, Premium 5EUR/mo):
  - Looks up existing price by lookup_key (freedge_essential_monthly,
    freedge_premium_monthly) — idempotent, safe to re-run
  - If missing, creates the product then the recurring price with the
    lookup_key and nickname for clarity
- Prints the resulting price IDs with their env var names
- With --write-env flag, automatically upserts the values into
  backend/.env preserving other lines
- Points to Customer Portal settings and stripe listen command as
  next steps

npm scripts added:
- npm run stripe:setup        # dry run, just print IDs
- npm run stripe:setup:write  # update .env automatically
- npm run stripe:listen       # shortcut for stripe CLI webhook forward

Updated README to show the script as the recommended path for step 1,
keeping the manual dashboard instructions as a fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:58:26 +02:00
ordinarthur
339de4c44c feat(stripe): full subscription flow (checkout, portal, webhooks)
Backend:
- Prisma: add stripeSubscriptionId, subscriptionStatus, priceId,
  currentPeriodEnd to User + migration SQL
- plugins/stripe.ts: getPlans catalog with env-based price IDs
- server.ts: raw body JSON parser for webhook signature verification,
  skip rate limit on /stripe/webhook
- types/fastify.d.ts: declare rawBody on FastifyRequest
- routes/stripe.ts (new):
  - GET  /stripe/plans        public
  - GET  /stripe/subscription user status
  - POST /stripe/checkout     hosted Checkout Session, lazy-creates
    customer, dynamic payment methods, promo codes enabled
  - POST /stripe/portal       Billing Portal session
  - POST /stripe/webhook      signature verified, handles
    checkout.session.completed, customer.subscription.*,
    invoice.payment_failed. Resolves user by clientReferenceId,
    metadata.userId, or stripeId fallback
- .env.example + README: Stripe setup, stripe CLI, test cards

Frontend:
- api/stripe.ts typed client (getPlans, getSubscription,
  startCheckout, openPortal)
- pages/Pricing.tsx: 3-card grid (free/essentiel/premium) with
  popular badge, current plan indicator, gradient popular card
- pages/CheckoutSuccess.tsx: animated confirmation with polling on
  /stripe/subscription until webhook activates plan
- pages/Profile.tsx: SubscriptionCard above tabs — free users see an
  upgrade banner, paid users see plan + status + next billing date
  + 'Gérer l'abonnement' button opening Customer Portal
- components/header.tsx: 'Tarifs' link in nav
- App.tsx: /pricing (public) and /checkout/success (protected) routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:54:27 +02:00
ordinarthur
0c4e0035c2 feat(ui): redesign Profile page (hero + tabs + danger zone)
Full rewrite of /profile with a cleaner layout and nicer polish.

Layout:
- Removed the sidebar 'user info' card; replaced with a horizontal hero
  at the top: avatar (with premium badge overlay), name, email, logout
  button, and 3 badges (plan, member since, recipe count)
- Tabs stretched full-width (no more 1/3 sidebar + 2/3 content split)
- Removed the 'Mes recettes' section from the bottom (redundant with
  /recipes list page)

Hero:
- 24-28 Avatar with gradient fallback and ring-4 separator
- Premium crown icon overlay (Sparkles badge) if subscription != 'free'
- Badges: plan tier, member since (localized French date), recipe count
- Logout button moved up here, styled as ghost with red hover

Cuisine tab (the main improvement):
- Diet is now a 4-button segmented control instead of a native <select>
- Max time and servings inputs have suffix labels ('min', 'pers.')
- Equipment checkboxes replaced by visual tile buttons with emoji
  icons, 2-col mobile / 4-col desktop, selected state = orange border +
  bg. Hidden actual checkbox for accessibility.
- 'Actif' badge in the card header when any pref is set
- Rounded xl inputs (h-11), brand gradient submit button

Other tabs (Profil/Email/Sécurité):
- Consistent visual language: h-11 rounded-xl inputs, h-12 brand
  gradient buttons, bolder labels
- Email tab shows current email in the description
- Improved placeholders

Alerts:
- Custom inline alerts with AnimatePresence, auto-dismiss after 4s for
  success, dismissible X button, CheckCircle2/AlertCircle icons
- No more shadcn Alert component (simpler + branded colors)

Danger zone:
- Dedicated bordered section at the bottom with icon + explanation
- Delete button opens a proper Dialog (replaces prompt() hack)
- Dialog asks for password, has Cancel/Delete buttons

Mobile polish:
- Hero stacks avatar on top, centered
- Tab triggers show icon-only on mobile, icon+label on sm+
- All 4 diet buttons fit in 2 cols on mobile
- Equipment tiles also 2 cols on mobile
- Logout button full-width on mobile

Removed all unused imports: AvatarImage, Separator, CardFooter, Alert,
AlertTitle, AlertDescription. Cleaned up handlers that weren't needed
(inline setState in onChange).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:42:54 +02:00
ordinarthur
7a3cbb9385 fix(ui): beef up review state action buttons (h-14, bold) 2026-04-08 13:39:06 +02:00
33 changed files with 3496 additions and 655 deletions

102
.gitea/workflows/deploy.yml Normal file
View 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

View File

@ -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

View File

@ -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
View 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"]

View File

@ -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}\""
},

View File

@ -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");

View File

@ -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
}
}

View 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));
});

View 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 é 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();

View File

@ -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,
};
}

View File

@ -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) =>

View 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;

View File

@ -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();
});

View File

@ -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[];

View File

@ -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',

View File

@ -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
View 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
View 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

View File

@ -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 />} />

View 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;

View File

@ -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 },
]

View File

@ -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,
};
}

View 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 é envoyé à ton adresse email.
</motion.p>
</div>
)
}

View 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

View File

@ -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

View 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 é 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
View 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
View 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
View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: freedge

12
k8s/pvc.yml Normal file
View 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
View 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
View 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;
}
}