From d8ffc61b177c4c3960512ed4e68fad89ece7dee2 Mon Sep 17 00:00:00 2001 From: streaper2 Date: Thu, 5 Mar 2026 14:04:07 +0100 Subject: [PATCH] ajout plan byok, plus correction d'affichage et navigation --- .gitignore | 2 + next-env.d.ts | 2 +- package-lock.json | 73 ++++++++++++++- package.json | 2 + prisma/schema.prisma | 4 + prisma/seed.ts | 22 ++++- src/app/api/user/profile/route.ts | 8 ++ src/app/checkout/page.tsx | 58 +++++++++++- src/app/pricing/page.tsx | 2 +- src/components/Checkout.tsx | 9 +- src/components/UserProfileSettings.tsx | 71 +++++++++++++- src/lib/gemini.ts | 123 +++++++++++++++++++------ src/lib/types.ts | 5 + 13 files changed, 336 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index b2026fa..b1de687 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ dist-ssr .next/dev/trace .next/dev/cache/turbopack/23c46498/CURRENT .next/dev/cache/turbopack/23c46498/LOG +.next/dev/cache/turbopack/23c46498/CURRENT +.next/dev/cache/turbopack/23c46498/LOG diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 37f592e..4e8fd07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "plumeia", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@google/genai": "^1.38.0", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.1", @@ -17,6 +18,7 @@ "lucide-react": "^0.563.0", "next": "16.1.6", "next-auth": "^5.0.0-beta.30", + "openai": "^6.25.0", "pg": "^8.19.0", "prisma": "^7.4.1", "react": "19.2.3", @@ -52,6 +54,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", + "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@auth/core": { "version": "0.41.0", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", @@ -273,6 +295,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -6008,6 +6039,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6962,6 +7006,27 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/openai": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz", + "integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8472,6 +8537,12 @@ "node": ">=8.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -9056,7 +9127,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index cbafdc3..deaa17b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "next lint" }, "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@google/genai": "^1.38.0", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.1", @@ -21,6 +22,7 @@ "lucide-react": "^0.563.0", "next": "16.1.6", "next-auth": "^5.0.0-beta.30", + "openai": "^6.25.0", "pg": "^8.19.0", "prisma": "^7.4.1", "react": "19.2.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9feb9ff..a81cca8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,10 @@ model User { planId String? @default("free") subscriptionPlan Plan? @relation(fields: [planId], references: [id]) + // Bring Your Own Key + customApiProvider String? + customApiKey String? + aiActionsUsed Int @default(0) dailyWordGoal Int @default(500) writingStreak Int @default(0) diff --git a/prisma/seed.ts b/prisma/seed.ts index df6fa76..f42ba8e 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,8 +1,13 @@ import { config } from 'dotenv'; -config({ path: '.env.local' }); -import getDB from '../src/lib/prisma'; +config(); +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Pool } from 'pg'; -const prisma = getDB(); +const connectionString = process.env.DATABASE_URL; +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); async function main() { console.log('Seeding plans...'); @@ -41,6 +46,17 @@ async function main() { features: ['250 actions IA / mois', 'Accès Gemini 3 Pro', 'Bible du monde avancée', 'Outils de révision avancés'], isPopular: false, }, + { + id: 'byok', + name: 'byok', + displayName: 'Clé Perso (BYOK)', + price: 4.99, + description: 'Utilisez vos propres clés API (ChatGPT, Claude, Gemini).', + maxProjects: -1, + maxAiActions: -1, + features: ['Tokens illimités via votre clé', 'Mode Bring Your Own Key', 'Choix du modèle IA', 'Projets illimités'], + isPopular: false, + }, ]; for (const plan of plans) { diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 85f22ae..d29d4ce 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -54,6 +54,8 @@ export async function GET() { dailyWordGoal: user.dailyWordGoal, writingStreak: user.writingStreak, lastWriteDate: user.lastWriteDate, + customApiProvider: user.customApiProvider, + customApiKey: user.customApiKey, createdAt: user.createdAt, totalWords, }); @@ -78,6 +80,12 @@ export async function PUT(request: NextRequest) { if (body.dailyWordGoal !== undefined) data.dailyWordGoal = body.dailyWordGoal; if (body.writingStreak !== undefined) data.writingStreak = body.writingStreak; if (body.lastWriteDate !== undefined) data.lastWriteDate = body.lastWriteDate ? new Date(body.lastWriteDate) : null; + if (body.customApiProvider !== undefined) data.customApiProvider = body.customApiProvider; + if (body.customApiKey !== undefined) data.customApiKey = body.customApiKey; + if (body.planId !== undefined) { + data.planId = body.planId; + data.plan = body.planId; // legacy sync + } const updated = await prisma.user.update({ where: { id: session.user.id }, diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index 9bd6f47..1edd52d 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -1,15 +1,67 @@ 'use client'; import Checkout from '@/components/Checkout'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState, Suspense } from 'react'; -export default function CheckoutPage() { +function CheckoutContent() { const router = useRouter(); + const searchParams = useSearchParams(); + const planId = searchParams.get('plan') || 'pro'; + + const [plan, setPlan] = useState<{ id: string, displayName: string, price: number } | null>(null); + + useEffect(() => { + fetch('/api/plans', { cache: 'no-store' }) + .then(res => res.json()) + .then(data => { + const found = data.find((p: any) => p.id === planId); + if (found) { + setPlan(found); + } else { + setPlan(data.find((p: any) => p.id === 'pro') || { id: 'pro', displayName: 'Auteur Pro', price: 12.00 }); + } + }) + .catch(err => { + console.error(err); + setPlan({ id: 'pro', displayName: 'Auteur Pro', price: 12.00 }); + }); + }, [planId]); + + const handleComplete = async () => { + if (plan) { + try { + await fetch('/api/user/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ planId: plan.id }) + }); + // Force a full reload to update user context in providers + window.location.href = '/dashboard'; + } catch (err) { + console.error('Failed to update plan', err); + router.push('/dashboard'); + } + } else { + router.push('/dashboard'); + } + }; + + if (!plan) return
Chargement...
; return ( router.push('/dashboard')} + plan={plan} + onComplete={handleComplete} onCancel={() => router.push('/pricing')} /> ); } + +export default function CheckoutPage() { + return ( + Chargement...}> + + + ); +} diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index c5e5227..31b3acc 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -31,7 +31,7 @@ export default function PricingPage() { isLoading={isLoading} currentPlan={user?.subscription.plan || 'free'} onBack={() => router.push(user ? '/dashboard' : '/')} - onSelectPlan={() => router.push(user ? '/checkout' : '/login')} + onSelectPlan={(id) => router.push(user ? `/checkout?plan=${id}` : '/login')} /> ); } diff --git a/src/components/Checkout.tsx b/src/components/Checkout.tsx index 6921112..1354d8b 100644 --- a/src/components/Checkout.tsx +++ b/src/components/Checkout.tsx @@ -6,11 +6,12 @@ import { CreditCard, Shield, Lock, ArrowRight, Loader2 } from 'lucide-react'; import { useLanguage } from '@/providers/LanguageProvider'; interface CheckoutProps { + plan: { id: string; displayName: string; price: number }; onComplete: () => void; onCancel: () => void; } -const Checkout: React.FC = ({ onComplete, onCancel }) => { +const Checkout: React.FC = ({ plan, onComplete, onCancel }) => { const [loading, setLoading] = useState(false); const { t } = useLanguage(); @@ -28,10 +29,10 @@ const Checkout: React.FC = ({ onComplete, onCancel }) => {

{t('checkout.order')}

-
{t('checkout.pro_author')}12.00€
-
{t('checkout.vat')}2.40€
+
{plan.displayName}{plan.price.toFixed(2)}€
+
{t('checkout.vat')} (20%){(plan.price * 0.2).toFixed(2)}€
-
{t('checkout.total')}14.40€
+
{t('checkout.total')}{(plan.price * 1.2).toFixed(2)}€
diff --git a/src/components/UserProfileSettings.tsx b/src/components/UserProfileSettings.tsx index 83634a5..7801150 100644 --- a/src/components/UserProfileSettings.tsx +++ b/src/components/UserProfileSettings.tsx @@ -6,6 +6,7 @@ import { UserProfile, UserPreferences } from '@/lib/types'; import { User, Settings, Globe, Shield, Bell, Save, Camera, Target, Flame, Layout } from 'lucide-react'; import { useLanguage } from '@/providers/LanguageProvider'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; +import { useRouter } from 'next/navigation'; interface UserProfileSettingsProps { user: UserProfile; @@ -36,9 +37,13 @@ const UserProfileSettings: React.FC = ({ user, onUpdat panel: '#24283b', text: '#c0caf5', accent: '#7aa2f7' - } + }, + customApiProvider: user.customApiProvider || 'openai', + customApiKey: user.customApiKey || '' }); + const router = useRouter(); + // Handle Live Preview of Theme Changes useEffect(() => { const root = document.documentElement; @@ -114,7 +119,9 @@ const UserProfileSettings: React.FC = ({ user, onUpdat theme: formData.theme, dailyWordGoal: formData.dailyWordGoal, customColors: formData.customColors - } + }, + customApiProvider: formData.customApiProvider as any, + customApiKey: formData.customApiKey }); localStorage.setItem('plumeia_theme', formData.theme); @@ -296,7 +303,12 @@ const UserProfileSettings: React.FC = ({ user, onUpdat

{t('profile.plan_title')} {(user.subscription.planDetails?.displayName || user.subscription.plan).toUpperCase()}

{t('profile.active_sub')}

- +
@@ -309,7 +321,58 @@ const UserProfileSettings: React.FC = ({ user, onUpdat />
-
+ {/* BYOK Settings */} + {user.subscription.plan === 'byok' && ( +
+

+ Vos clés API (BYOK) +

+

+ Vous avez l'abonnement "Clé Perso". Vous pouvez utiliser l'IA en illimité en renseignant votre propre clé API. +

+ +
+
+ + +
+ +
+ + setFormData({ ...formData, customApiKey: e.target.value })} + placeholder="Collez votre clé secrète ici..." + className={`w-full p-3 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm ${themeInputBg}`} + /> +
+
+
+ )} + + {user.subscription.plan !== 'byok' && user.subscription.plan !== 'master' && ( +
+

Passer à l'abonnement "Clé Perso (BYOK)" ?

+

Pour 4,99€/mois, utilisez vos propres clés API ChatGPT, Claude ou Gemini sans limite de tokens.

+ +
+ )} + +
diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts index 9e3d371..f33f4e3 100644 --- a/src/lib/gemini.ts +++ b/src/lib/gemini.ts @@ -2,6 +2,8 @@ // This file is only imported by API routes, never by client code import { GoogleGenAI, Type } from "@google/genai"; +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; import { BookProject, UserProfile } from "./types"; const truncate = (str: string, length: number) => { @@ -10,7 +12,8 @@ const truncate = (str: string, length: number) => { }; const checkUsage = (user: UserProfile) => { - if (user.subscription.plan === 'master') return true; + // BYOK and Master plans have unlimited AI actions + if (user.subscription.plan === 'master' || user.subscription.plan === 'byok') return true; return user.usage.aiActionsCurrent < user.usage.aiActionsLimit; }; @@ -86,37 +89,77 @@ export const generateStoryContent = async ( } try { - const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); const finalPrompt = buildContextPrompt(project, currentChapterId, userPrompt); - const modelName = user.subscription.plan === 'master' ? 'gemini-3-pro-preview' : 'gemini-3-flash-preview'; + // --- BYOK: Bring Your Own Key Logic --- + const isBYOK = user.subscription.plan === 'byok' && user.customApiKey; + const provider = isBYOK ? (user.customApiProvider || 'openai') : 'gemini'; - const response = await ai.models.generateContent({ - model: modelName, - contents: finalPrompt, - config: { + if (provider === 'openai' && isBYOK) { + const openai = new OpenAI({ apiKey: user.customApiKey }); + const completion = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: finalPrompt }], + response_format: { type: "json_object" }, temperature: 0.7, - responseMimeType: "application/json", - responseSchema: { - type: Type.OBJECT, - properties: { - responseType: { - type: Type.STRING, - enum: ["draft", "reflection"] - }, - content: { - type: Type.STRING + }); + const result = JSON.parse(completion.choices[0].message.content || "{}"); + return { + text: result.content || "Erreur de génération.", + type: result.responseType || "reflection" + }; + } + else if (provider === 'anthropic' && isBYOK) { + const anthropic = new Anthropic({ apiKey: user.customApiKey }); + // Anthropic doesn't have strict JSON mode matching OpenAI's structure natively at this tier, + // so we prompt it to return JSON. + const systemPrompt = "You must respond in valid JSON format with exact keys: `responseType` (either 'draft' or 'reflection') and `content` (string)."; + const message = await anthropic.messages.create({ + model: "claude-3-haiku-20240320", + max_tokens: 4000, + system: systemPrompt, + messages: [{ role: "user", content: finalPrompt }], + }); + const textContent = message.content[0].type === 'text' ? message.content[0].text : '{}'; + const result = JSON.parse(textContent); + return { + text: result.content || "Erreur de génération.", + type: result.responseType || "reflection" + }; + } + else { + // Default: Pluume Gemini implementation + const ai = new GoogleGenAI({ apiKey: isBYOK && user.customApiProvider === 'gemini' ? user.customApiKey : process.env.GEMINI_API_KEY }); + const modelName = user.subscription.plan === 'master' ? 'gemini-3-pro-preview' : 'gemini-3-flash-preview'; + + const response = await ai.models.generateContent({ + model: modelName, + contents: finalPrompt, + config: { + temperature: 0.7, + responseMimeType: "application/json", + responseSchema: { + type: Type.OBJECT, + properties: { + responseType: { + type: Type.STRING, + enum: ["draft", "reflection"] + }, + content: { + type: Type.STRING + } } } } - } - }); + }); + + const result = JSON.parse(response.text || "{}"); + return { + text: result.content || "Erreur de génération.", + type: result.responseType || "reflection" + }; + } - const result = JSON.parse(response.text || "{}"); - return { - text: result.content || "Erreur de génération.", - type: result.responseType || "reflection" - }; } catch (error) { console.error("AI Generation Error:", error); return { text: "Erreur lors de la communication avec l'IA.", type: 'reflection' }; @@ -130,11 +173,35 @@ export const transformTextServer = async ( user: UserProfile, ): Promise => { if (!checkUsage(user)) return "Limite d'actions IA atteinte."; + try { - const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); - const prompt = `Action: ${mode}. Texte: ${text}. Contexte: ${truncate(context, 1000)}. Renvoie juste le texte transformé.`; - const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: prompt }); - return response.text?.trim() || text; + const prompt = `Action: ${mode}. Texte: ${text}. Contexte: ${truncate(context, 1000)}. Renvoie juste le texte transformé sans commentaires.`; + + const isBYOK = user.subscription.plan === 'byok' && user.customApiKey; + const provider = isBYOK ? (user.customApiProvider || 'openai') : 'gemini'; + + if (provider === 'openai' && isBYOK) { + const openai = new OpenAI({ apiKey: user.customApiKey }); + const completion = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return completion.choices[0].message.content || text; + } + else if (provider === 'anthropic' && isBYOK) { + const anthropic = new Anthropic({ apiKey: user.customApiKey }); + const message = await anthropic.messages.create({ + model: "claude-3-haiku-20240320", + max_tokens: 4000, + messages: [{ role: "user", content: prompt }], + }); + return message.content[0].type === 'text' ? message.content[0].text : text; + } + else { + const ai = new GoogleGenAI({ apiKey: isBYOK && user.customApiProvider === 'gemini' ? user.customApiKey : process.env.GEMINI_API_KEY }); + const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: prompt }); + return response.text?.trim() || text; + } } catch { return text; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 230611f..e8e0200 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -164,6 +164,9 @@ export interface UserPreferences { }; dailyWordGoal: number; language: 'fr' | 'en'; + customApiProvider?: 'openai' | 'anthropic' | 'gemini'; + // We can store the masked key or omit it from the client + customApiKey?: string; } export interface UserStats { @@ -182,6 +185,8 @@ export interface UserProfile { usage: UserUsage; preferences: UserPreferences; stats: UserStats; + customApiProvider?: 'openai' | 'anthropic' | 'gemini'; + customApiKey?: string; } export type ViewMode = 'write' | 'world_building' | 'workflow' | 'settings' | 'preview' | 'ideas' | 'landing' | 'features' | 'pricing' | 'checkout' | 'dashboard' | 'auth' | 'signup' | 'profile';