ajout plan byok, plus correction d'affichage et navigation

This commit is contained in:
2026-03-05 14:04:07 +01:00
parent d004281e05
commit d8ffc61b17
13 changed files with 336 additions and 45 deletions

2
.gitignore vendored
View File

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

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

73
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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