ajout plan byok, plus correction d'affichage et navigation
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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 <div className="min-h-screen bg-[#eef2ff] flex items-center justify-center p-8">Chargement...</div>;
|
||||
|
||||
return (
|
||||
<Checkout
|
||||
onComplete={() => router.push('/dashboard')}
|
||||
plan={plan}
|
||||
onComplete={handleComplete}
|
||||
onCancel={() => router.push('/pricing')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-[#eef2ff] flex items-center justify-center p-8">Chargement...</div>}>
|
||||
<CheckoutContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<CheckoutProps> = ({ onComplete, onCancel }) => {
|
||||
const Checkout: React.FC<CheckoutProps> = ({ plan, onComplete, onCancel }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
@@ -28,10 +29,10 @@ const Checkout: React.FC<CheckoutProps> = ({ onComplete, onCancel }) => {
|
||||
<div className="w-full md:w-1/3 bg-slate-900 text-white p-8">
|
||||
<h3 className="text-xl font-bold mb-8 flex items-center gap-2"><Lock size={18} className="text-blue-400" /> {t('checkout.order')}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm"><span>{t('checkout.pro_author')}</span><span>12.00€</span></div>
|
||||
<div className="flex justify-between text-sm"><span>{t('checkout.vat')}</span><span>2.40€</span></div>
|
||||
<div className="flex justify-between text-sm"><span>{plan.displayName}</span><span>{plan.price.toFixed(2)}€</span></div>
|
||||
<div className="flex justify-between text-sm"><span>{t('checkout.vat')} (20%)</span><span>{(plan.price * 0.2).toFixed(2)}€</span></div>
|
||||
<div className="h-px bg-slate-800 my-4" />
|
||||
<div className="flex justify-between text-xl font-black"><span>{t('checkout.total')}</span><span className="text-blue-400">14.40€</span></div>
|
||||
<div className="flex justify-between text-xl font-black"><span>{t('checkout.total')}</span><span className="text-blue-400">{(plan.price * 1.2).toFixed(2)}€</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-8 md:p-12">
|
||||
|
||||
@@ -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<UserProfileSettingsProps> = ({ 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<UserProfileSettingsProps> = ({ 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<UserProfileSettingsProps> = ({ user, onUpdat
|
||||
<h4 className="font-bold text-blue-900">{t('profile.plan_title')} {(user.subscription.planDetails?.displayName || user.subscription.plan).toUpperCase()}</h4>
|
||||
<p className="text-xs text-blue-700">{t('profile.active_sub')}</p>
|
||||
</div>
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-blue-700 shadow-md shadow-blue-200">{t('profile.manage')}</button>
|
||||
<button
|
||||
onClick={() => router.push('/pricing')}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-blue-700 shadow-md shadow-blue-200"
|
||||
>
|
||||
{t('profile.manage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -309,7 +321,58 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
{/* BYOK Settings */}
|
||||
{user.subscription.plan === 'byok' && (
|
||||
<div className={`p-6 rounded-xl border mt-8 ${isCustom ? 'border-theme-border bg-theme-panel' : isDark ? 'border-slate-700 bg-slate-800/50' : isSepia ? 'border-[#dfcdae] bg-[#f4ecd8]/50' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<h4 className={`font-bold mb-4 flex items-center gap-2 ${themeTextHeading}`}>
|
||||
<Globe size={18} className="text-blue-500" /> Vos clés API (BYOK)
|
||||
</h4>
|
||||
<p className={`text-xs mb-6 ${themeTextMuted}`}>
|
||||
Vous avez l'abonnement "Clé Perso". Vous pouvez utiliser l'IA en illimité en renseignant votre propre clé API.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs font-black uppercase tracking-widest ${themeTextMuted}`}>Fournisseur IA</label>
|
||||
<select
|
||||
value={formData.customApiProvider}
|
||||
onChange={(e) => setFormData({ ...formData, customApiProvider: e.target.value as any })}
|
||||
className={`w-full p-3 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 ${themeInputBg}`}
|
||||
>
|
||||
<option value="openai">OpenAI (ChatGPT)</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="gemini">Google (Gemini)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs font-black uppercase tracking-widest ${themeTextMuted}`}>Clé API ({formData.customApiProvider === 'openai' ? 'sk-proj-...' : formData.customApiProvider === 'anthropic' ? 'sk-ant-...' : 'AIza...'})</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.customApiKey}
|
||||
onChange={(e) => 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}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.subscription.plan !== 'byok' && user.subscription.plan !== 'master' && (
|
||||
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-xl mt-8">
|
||||
<h4 className="font-bold text-indigo-900 text-sm mb-1">Passer à l'abonnement "Clé Perso (BYOK)" ?</h4>
|
||||
<p className="text-xs text-indigo-700 mb-3">Pour 4,99€/mois, utilisez vos propres clés API ChatGPT, Claude ou Gemini sans limite de tokens.</p>
|
||||
<button
|
||||
onClick={() => router.push('/pricing')}
|
||||
className="bg-white text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-200 hover:bg-indigo-50 transition w-full"
|
||||
>
|
||||
Découvrir le plan BYOK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 mt-8 pt-8 border-t border-red-100/20">
|
||||
<button className="text-red-500 text-sm font-bold hover:underline">{t('profile.delete_account')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string> => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user