feat: Implement pricing page, AI generation/transformation APIs, user/project management, and database schema.

This commit is contained in:
2026-02-27 23:08:56 +01:00
parent ec89ddc9dc
commit 23560ac9c3
18 changed files with 837 additions and 94 deletions

View File

@@ -5,11 +5,7 @@ import { auth } from '@/lib/auth';
import getDB from '@/lib/prisma';
import { generateStoryContent } from '@/lib/gemini';
const PLAN_AI_LIMITS: Record<string, number> = {
free: 100,
pro: 5000,
master: 999999,
};
export async function POST(request: NextRequest) {
try {
@@ -23,17 +19,19 @@ export async function POST(request: NextRequest) {
// Check AI usage limit from DB
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { plan: true, aiActionsUsed: true },
});
include: { subscriptionPlan: true },
}) as any; // Bypass Prisma client types for this relation
if (!dbUser) {
return NextResponse.json({ error: 'Utilisateur non trouvé' }, { status: 404 });
}
const limit = PLAN_AI_LIMITS[dbUser.plan] || PLAN_AI_LIMITS.free;
if (dbUser.aiActionsUsed >= limit) {
const limit = dbUser.subscriptionPlan?.maxAiActions ?? 100;
const planName = dbUser.subscriptionPlan?.displayName || 'Gratuit';
if (limit !== -1 && dbUser.aiActionsUsed >= limit) {
return NextResponse.json(
{ error: `Limite de ${limit} actions IA atteinte pour le plan ${dbUser.plan}. Passez au plan supérieur !` },
{ error: `Limite de ${limit} actions IA atteinte pour le plan ${planName}. Passez au plan supérieur !` },
{ status: 403 }
);
}

View File

@@ -5,11 +5,7 @@ import { auth } from '@/lib/auth';
import getDB from '@/lib/prisma';
import { transformTextServer } from '@/lib/gemini';
const PLAN_AI_LIMITS: Record<string, number> = {
free: 100,
pro: 5000,
master: 999999,
};
export async function POST(request: NextRequest) {
try {
@@ -23,17 +19,19 @@ export async function POST(request: NextRequest) {
// Check AI usage limit from DB
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { plan: true, aiActionsUsed: true },
});
include: { subscriptionPlan: true },
}) as any; // Bypass Prisma type cache
if (!dbUser) {
return NextResponse.json({ error: 'Utilisateur non trouvé' }, { status: 404 });
}
const limit = PLAN_AI_LIMITS[dbUser.plan] || PLAN_AI_LIMITS.free;
if (dbUser.aiActionsUsed >= limit) {
const limit = dbUser.subscriptionPlan?.maxAiActions ?? 100;
const planName = dbUser.subscriptionPlan?.displayName || 'Gratuit';
if (limit !== -1 && dbUser.aiActionsUsed >= limit) {
return NextResponse.json(
{ error: `Limite de ${limit} actions IA atteinte pour le plan ${dbUser.plan}. Passez au plan supérieur !` },
{ error: `Limite de ${limit} actions IA atteinte pour le plan ${planName}. Passez au plan supérieur !` },
{ status: 403 }
);
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import getDB from '@/lib/prisma';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const prisma = getDB();
const plans = await prisma.plan.findMany({
orderBy: { price: 'asc' }
});
const response = NextResponse.json(plans);
response.headers.set('Cache-Control', 'no-store, max-age=0');
return response;
} catch (error) {
console.error('Failed to fetch plans', error);
return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 });
}
}

View File

@@ -22,12 +22,7 @@ export async function GET() {
return NextResponse.json(projects);
}
// Plan limits for project creation
const PLAN_PROJECT_LIMITS: Record<string, number> = {
free: 3,
pro: 20,
master: 999,
};
// POST /api/projects — Create a new project
export async function POST(request: NextRequest) {
@@ -36,17 +31,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
// Check plan limits
const prisma = getDB();
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { subscriptionPlan: true }
}) as any; // Cast to any to bypass Prisma type cache issues
// Check plan limit
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { plan: true } });
const plan = user?.plan || 'free';
const limit = PLAN_PROJECT_LIMITS[plan] || PLAN_PROJECT_LIMITS.free;
const limit = user?.subscriptionPlan?.maxProjects ?? 3;
const planName = user?.subscriptionPlan?.displayName || 'Gratuit';
const currentCount = await prisma.project.count({ where: { userId: session.user.id } });
if (currentCount >= limit) {
if (limit !== -1 && currentCount >= limit) {
return NextResponse.json(
{ error: `Limite de ${limit} projets atteinte pour le plan ${plan}. Passez au plan supérieur !` },
{ error: `Limite de ${limit} projets atteinte pour le plan ${planName}. Passez au plan supérieur !` },
{ status: 403 }
);
}

View File

@@ -14,7 +14,8 @@ export async function GET() {
const prisma = getDB();
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
include: { subscriptionPlan: true }
} as any) as any; // Bypass Prisma type cache
if (!user) {
return NextResponse.json({ error: 'Utilisateur non trouvé' }, { status: 404 });
@@ -31,13 +32,24 @@ export async function GET() {
return total + (text ? text.split(/\s+/).length : 0);
}, 0);
return NextResponse.json({
const response = NextResponse.json({
id: user.id,
email: user.email,
name: user.name,
avatar: user.avatar,
bio: user.bio,
plan: user.plan,
plan: user.planId || user.plan || 'free',
planDetails: user.subscriptionPlan ? {
id: user.subscriptionPlan.id,
name: user.subscriptionPlan.name,
displayName: user.subscriptionPlan.displayName,
price: user.subscriptionPlan.price,
description: user.subscriptionPlan.description,
features: user.subscriptionPlan.features,
maxProjects: user.subscriptionPlan.maxProjects,
maxAiActions: user.subscriptionPlan.maxAiActions,
isPopular: user.subscriptionPlan.isPopular
} : undefined,
aiActionsUsed: user.aiActionsUsed,
dailyWordGoal: user.dailyWordGoal,
writingStreak: user.writingStreak,
@@ -45,6 +57,8 @@ export async function GET() {
createdAt: user.createdAt,
totalWords,
});
response.headers.set('Cache-Control', 'no-store, max-age=0');
return response;
}
// PUT /api/user/profile — Update user profile

View File

@@ -1,5 +1,6 @@
'use client';
import React, { useState, useEffect } from 'react';
import Pricing from '@/components/Pricing';
import { useRouter } from 'next/navigation';
import { useAuthContext } from '@/providers/AuthProvider';
@@ -8,8 +9,26 @@ export default function PricingPage() {
const router = useRouter();
const { user } = useAuthContext();
const [plans, setPlans] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch('/api/plans', { cache: 'no-store' })
.then(res => res.json())
.then(data => {
setPlans(data);
setIsLoading(false);
})
.catch(err => {
console.error(err);
setIsLoading(false);
});
}, []);
return (
<Pricing
plans={plans}
isLoading={isLoading}
currentPlan={user?.subscription.plan || 'free'}
onBack={() => router.push(user ? '/dashboard' : '/')}
onSelectPlan={() => router.push(user ? '/checkout' : '/login')}

View File

@@ -81,9 +81,28 @@ const AppRouter: React.FC<AppRouterProps> = (props) => {
// but AuthPage manages its own state. We can pass a prop if AuthPage supports it, or just let user toggle.
// Since AuthPage has internal state for mode, we might just render it.
// Ideally AuthPage should accept an initialMode prop. Let's check AuthPage again or just render it.
const [plans, setPlans] = useState<any[]>([]);
const [isPricingLoading, setIsPricingLoading] = useState(true);
React.useEffect(() => {
if (viewMode === 'pricing' && plans.length === 0) {
setIsPricingLoading(true);
fetch('/api/plans', { cache: 'no-store' })
.then(res => res.json())
.then(data => {
setPlans(data);
setIsPricingLoading(false);
})
.catch(err => {
console.error(err);
setIsPricingLoading(false);
});
}
}, [viewMode, plans.length]);
if (viewMode === 'signup') return <AuthPage onBack={() => props.onViewModeChange('landing')} onSuccess={() => props.onViewModeChange('dashboard')} initialMode='signup' />;
if (viewMode === 'features') return <FeaturesPage onBack={() => props.onViewModeChange(user ? 'dashboard' : 'landing')} />;
if (viewMode === 'pricing') return <Pricing currentPlan={user?.subscription.plan || 'free'} onBack={() => props.onViewModeChange(user ? 'dashboard' : 'landing')} onSelectPlan={() => user ? props.onViewModeChange('checkout') : props.onViewModeChange('auth')} />;
if (viewMode === 'pricing') return <Pricing plans={plans} isLoading={isPricingLoading} currentPlan={user?.subscription.plan || 'free'} onBack={() => props.onViewModeChange(user ? 'dashboard' : 'landing')} onSelectPlan={() => user ? props.onViewModeChange('checkout') : props.onViewModeChange('auth')} />;
if (viewMode === 'checkout') return <Checkout onComplete={() => props.onUpgradePlan('pro')} onCancel={() => props.onViewModeChange('pricing')} />;
if (viewMode === 'dashboard' && user) return <Dashboard user={user} projects={projects} onSelect={props.onSelectProject} onCreate={props.onCreateProject} onLogout={props.onLogout} onPricing={() => props.onViewModeChange('pricing')} onProfile={() => props.onViewModeChange('profile')} />;
if (viewMode === 'profile' && user) return <UserProfileSettings user={user} onUpdate={props.onUpdateProfile} onBack={() => props.onViewModeChange('dashboard')} />;

View File

@@ -30,7 +30,7 @@ const Dashboard: React.FC<DashboardProps> = ({ user, projects, onSelect, onCreat
<div>
<h2 className="text-3xl font-black text-slate-900">Bonjour, {user.name} 👋</h2>
<div className="flex items-center gap-3 mt-1">
<span className="px-3 py-1 rounded-full bg-indigo-100 text-indigo-700 text-[10px] uppercase font-black tracking-widest">{user.subscription.plan}</span>
<span className="px-3 py-1 rounded-full bg-indigo-100 text-indigo-700 text-[10px] uppercase font-black tracking-widest">{user.subscription.planDetails?.displayName || user.subscription.plan}</span>
<span className="text-slate-400 text-xs font-medium">Membre depuis le 24 janv.</span>
</div>
</div>

View File

@@ -3,55 +3,61 @@
import React from 'react';
import { Check, ArrowLeft } from 'lucide-react';
import { PlanType } from '@/lib/types';
interface PricingProps {
currentPlan: PlanType;
onBack: () => void;
onSelectPlan: (plan: PlanType) => void;
interface PlanData {
id: string;
name: string;
displayName: string;
price: number;
description: string;
features: string[];
isPopular: boolean;
}
const Pricing: React.FC<PricingProps> = ({ currentPlan, onBack, onSelectPlan }) => {
const plans = [
{ id: 'free', name: 'Gratuit', price: '0€', desc: 'Idéal pour découvrir PlumeIA.', features: ['10 actions IA / mois', '1 projet actif', 'Bible du monde simple'] },
{ id: 'pro', name: 'Auteur Pro', price: '12€', desc: 'Pour les écrivains sérieux.', features: ['500 actions IA / mois', 'Projets illimités', 'Export Word & EPUB', 'Support prioritaire'], popular: true },
{ id: 'master', name: 'Maître Plume', price: '29€', desc: 'Le summum de l\'écriture IA.', features: ['Actions IA illimitées', 'Accès Gemini 3 Pro', 'Bible du monde avancée', 'Outils de révision avancés'] },
];
interface PricingProps {
plans: PlanData[];
currentPlan: string;
onBack: () => void;
onSelectPlan: (planId: string) => void;
isLoading?: boolean;
}
const Pricing: React.FC<PricingProps> = ({ plans, currentPlan, onBack, onSelectPlan, isLoading }) => {
return (
<div className="min-h-screen bg-[#eef2ff] py-20 px-8">
<div className="max-w-6xl mx-auto">
<button onClick={onBack} className="flex items-center gap-2 text-slate-500 hover:text-blue-600 mb-12 font-bold transition-colors">
<ArrowLeft size={20} /> Retour
<ArrowLeft size={20} /> Retour
</button>
<div className="text-center mb-16">
<h2 className="text-4xl font-black text-slate-900 mb-4">Choisissez votre destin d'écrivain.</h2>
<p className="text-slate-500">Passez au plan supérieur pour libérer toute la puissance de l'IA.</p>
<h2 className="text-4xl font-black text-slate-900 mb-4">Choisissez votre destin d'écrivain.</h2>
<p className="text-slate-500">Passez au plan supérieur pour libérer toute la puissance de l'IA.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{plans.map((p) => (
<div key={p.id} className={`bg-white rounded-3xl p-8 border transition-all ${p.popular ? 'border-blue-500 shadow-2xl scale-105 z-10' : 'border-indigo-100 shadow-xl'}`}>
<div className="mb-8">
<h4 className="text-xl font-bold text-slate-900 mb-2">{p.name}</h4>
<div className="text-4xl font-black text-slate-900 mb-2">{p.price}<span className="text-sm font-normal text-slate-400">/mois</span></div>
<p className="text-sm text-slate-500">{p.desc}</p>
</div>
<ul className="space-y-4 mb-10">
{p.features.map((f, i) => (
<li key={i} className="flex items-center gap-3 text-sm text-slate-700">
<div className="text-blue-500 bg-blue-50 p-0.5 rounded-full"><Check size={14} /></div>
{f}
</li>
))}
</ul>
<button
onClick={() => onSelectPlan(p.id as PlanType)}
className={`w-full py-4 rounded-2xl font-black transition-all ${p.id === currentPlan ? 'bg-slate-100 text-slate-400 cursor-default' : p.popular ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-900 text-white hover:bg-slate-800'}`}
>
{p.id === currentPlan ? 'Plan Actuel' : 'Sélectionner'}
</button>
</div>
))}
{isLoading && <p className="text-center col-span-3 py-10">Chargement des offres...</p>}
{!isLoading && plans.map((p) => (
<div key={p.id} className={`bg-white rounded-3xl p-8 border transition-all ${p.isPopular ? 'border-blue-500 shadow-2xl scale-105 z-10' : 'border-indigo-100 shadow-xl'}`}>
<div className="mb-8">
<h4 className="text-xl font-bold text-slate-900 mb-2">{p.displayName}</h4>
<div className="text-4xl font-black text-slate-900 mb-2">{p.price}<span className="text-sm font-normal text-slate-400">/mois</span></div>
<p className="text-sm text-slate-500">{p.description}</p>
</div>
<ul className="space-y-4 mb-10">
{p.features.map((f, i) => (
<li key={i} className="flex items-center gap-3 text-sm text-slate-700">
<div className="text-blue-500 bg-blue-50 p-0.5 rounded-full"><Check size={14} /></div>
{f}
</li>
))}
</ul>
<button
onClick={() => onSelectPlan(p.id)}
className={`w-full py-4 rounded-2xl font-black transition-all ${p.id === currentPlan ? 'bg-slate-100 text-slate-400 cursor-default' : p.isPopular ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-900 text-white hover:bg-slate-800'}`}
>
{p.id === currentPlan ? 'Plan Actuel' : 'Sélectionner'}
</button>
</div>
))}
</div>
</div>
</div>

View File

@@ -165,7 +165,7 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-300">
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl flex justify-between items-center">
<div>
<h4 className="font-bold text-blue-900">Plan {user.subscription.plan.toUpperCase()}</h4>
<h4 className="font-bold text-blue-900">Plan {(user.subscription.planDetails?.displayName || user.subscription.plan).toUpperCase()}</h4>
<p className="text-xs text-blue-700">Prochaine facturation le 15 Mars 2024</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">Gérer</button>

View File

@@ -2,15 +2,9 @@
import { useState, useEffect, useCallback } from 'react';
import { signIn, signOut, useSession } from 'next-auth/react';
import { UserProfile, PlanType } from '@/lib/types';
import { UserProfile } from '@/lib/types';
import api from '@/lib/api';
const PLAN_LIMITS: Record<string, { aiActions: number; projects: number }> = {
free: { aiActions: 100, projects: 3 },
pro: { aiActions: 5000, projects: 20 },
master: { aiActions: 999999, projects: 999 },
};
export const useAuth = () => {
const { data: session, status } = useSession();
const [user, setUser] = useState<UserProfile | null>(null);
@@ -19,11 +13,16 @@ export const useAuth = () => {
// Fetch real profile from DB when session is available
useEffect(() => {
if (session?.user?.id) {
fetch('/api/user/profile')
fetch('/api/user/profile', { cache: 'no-store' })
.then(res => res.json())
.then(dbUser => {
const plan = (dbUser.plan || 'free') as PlanType;
const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
const planId = dbUser.plan || 'free';
const planDetails = dbUser.planDetails || {
id: 'free',
displayName: 'Gratuit',
maxAiActions: 100,
maxProjects: 3
};
setUser({
id: dbUser.id,
@@ -31,11 +30,16 @@ export const useAuth = () => {
name: dbUser.name || 'User',
avatar: dbUser.avatar,
bio: dbUser.bio,
subscription: { plan, startDate: new Date(dbUser.createdAt).getTime(), status: 'active' },
subscription: {
plan: planId,
planDetails: planDetails,
startDate: new Date(dbUser.createdAt).getTime(),
status: 'active'
},
usage: {
aiActionsCurrent: dbUser.aiActionsUsed || 0,
aiActionsLimit: limits.aiActions,
projectsLimit: limits.projects,
aiActionsLimit: planDetails.maxAiActions,
projectsLimit: planDetails.maxProjects,
},
preferences: { theme: 'light', dailyWordGoal: dbUser.dailyWordGoal || 500, language: 'fr' },
stats: {

View File

@@ -129,10 +129,21 @@ export interface ChatMessage {
// --- SAAS TYPES ---
export type PlanType = 'free' | 'pro' | 'master';
export interface PlanData {
id: string;
name: string;
displayName: string;
price: number;
description: string;
features: string[];
isPopular: boolean;
maxProjects: number;
maxAiActions: number;
}
export interface Subscription {
plan: PlanType;
plan: string; // The ID of the plan
planDetails?: PlanData; // The populated plan details from DB
startDate: number;
status: 'active' | 'canceled' | 'past_due';
}