mie a jour de la des mots du jour,

optimisation de la latence des pages
ajout d'option  clique droit plus paragraphe
This commit is contained in:
2026-03-14 20:45:59 +01:00
parent 1f77255a6b
commit 12de3a8328
28 changed files with 492 additions and 178 deletions

3
.env
View File

@@ -4,4 +4,5 @@ DATABASE_URL="postgresql://admin:adminpassword@plumedb.kaelstudio.tech:5432/plum
BETTER_AUTH_SECRET=cbe18fc18cd18fa590bd8be51204fac2ee0b1cc704adf8fa0192f77d42b262dd
AUTH_SECRET=cbe18fc18cd18fa590bd8be51204fac2ee0b1cc704adf8fa0192f77d42b262dd
# Server-only
GEMINI_API_KEY=AIzaSyBjMxaRq4psBbvtdks0iYGkv-r9midKSh4
GEMINI_API_KEY=AIzaSyBjMxaRq4psBbvtdks0iYGkv-r9midKSh4
STRIPE_SECRET_KEY=sk_test_51T8SBN2WIo50AZ0wy5YbNVscHHw6y10z1aB0Ho54iqHHJ4ROYAQRMQEi42sgI8dknnGsHOxgo5tpw9UZA9QE2xIR00R5WD9BjB

Binary file not shown.

View File

@@ -6128,3 +6128,9 @@ FAM | META SEQ | SST SEQ | RANGE
0 | 00015703 | 00015702 SST | [=======================================================================] | 3aefa6fd5cf2deb4-f42f94001fcb5351 (0 MiB, fresh)
1 | 00015704 | 00015700 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)
2 | 00015705 | 00015701 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)
Time 2026-03-14T19:42:53.4660378Z
Commit 00001865 4 keys in 5ms 888µs 400ns
FAM | META SEQ | SST SEQ | RANGE
0 | 00001863 | 00001862 SST | [=======================================================================] | 3aefa6fd5cf2deb4-f42f94001fcb5351 (0 MiB, fresh)
1 | 00001864 | 00001860 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)
2 | 00001865 | 00001861 SST | O | 3ffdfb3b7d50fcf1-3ffdfb3b7d50fcf1 (0 MiB, fresh)

20
package-lock.json generated
View File

@@ -23,7 +23,8 @@
"prisma": "^7.4.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"server-only": "^0.0.1"
"server-only": "^0.0.1",
"stripe": "^20.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -8397,6 +8398,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "20.4.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz",
"integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

View File

@@ -27,7 +27,8 @@
"prisma": "^7.4.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"server-only": "^0.0.1"
"server-only": "^0.0.1",
"stripe": "^20.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -46,8 +46,16 @@ model User {
aiActionsUsed Int @default(0)
dailyWordGoal Int @default(500)
dailyWordCount Int @default(0)
writingStreak Int @default(0)
lastWriteDate DateTime?
// Stripe Integration
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -37,13 +37,20 @@ export async function PUT(
});
// Update writing streak if content was explicitly updated
let userStats = null;
if (body.content !== undefined) {
import('@/lib/streak').then(({ updateWritingStreak }) => {
updateWritingStreak(session.user.id).catch(console.error);
});
const oldWords = (chapter.content || '').replace(/<[^>]*>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
const newWords = (body.content || '').replace(/<[^>]*>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
const wordDelta = Math.max(0, newWords - oldWords);
const { updateWritingStreak } = await import('@/lib/streak');
userStats = await updateWritingStreak(session.user.id, wordDelta);
}
return NextResponse.json(updated);
return NextResponse.json({
...updated,
_userStats: userStats
});
}
// DELETE /api/chapters/[id]

View File

@@ -35,8 +35,9 @@ export async function POST(request: NextRequest) {
});
// Update writing streak
const wordCount = (body.content || '').replace(/<[^>]*>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
import('@/lib/streak').then(({ updateWritingStreak }) => {
updateWritingStreak(session.user.id).catch(console.error);
updateWritingStreak(session.user.id, wordCount).catch(console.error);
});
return NextResponse.json(chapter, { status: 201 });

View File

@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
export async function POST(req: Request) {
try {
const session = await auth();
if (!session?.user?.email) {
return new NextResponse('Unauthorized', { status: 401 });
}
const { planId } = await req.json();
if (!planId) {
return new NextResponse('Plan ID is required', { status: 400 });
}
const plan = await prisma.plan.findUnique({
where: { id: planId },
});
if (!plan) {
return new NextResponse('Plan not found', { status: 404 });
}
// Free plan doesn't need checkout
if (plan.id === 'free') {
return new NextResponse('Free plan does not require payment', { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user) {
return new NextResponse('User not found', { status: 404 });
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
customer_email: session.user.email,
line_items: [
{
price_data: {
currency: 'eur',
product_data: {
name: plan.displayName,
description: plan.description,
},
unit_amount: Math.round(plan.price * 100),
recurring: {
interval: 'month',
},
},
quantity: 1,
},
],
metadata: {
userId: user.id,
planId: plan.id,
},
success_url: `${baseUrl}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/pricing`,
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('[STRIPE_CHECKOUT_ERROR]', error);
return new NextResponse('Internal Error', { status: 500 });
}
}

View File

@@ -54,6 +54,7 @@ export async function GET() {
dailyWordGoal: user.dailyWordGoal,
writingStreak: user.writingStreak,
lastWriteDate: user.lastWriteDate,
dailyWordCount: user.dailyWordCount || 0,
customApiProvider: user.customApiProvider,
customApiKey: user.customApiKey,
createdAt: user.createdAt,
@@ -78,6 +79,7 @@ export async function PUT(request: NextRequest) {
if (body.avatar !== undefined) data.avatar = body.avatar;
if (body.bio !== undefined) data.bio = body.bio;
if (body.dailyWordGoal !== undefined) data.dailyWordGoal = body.dailyWordGoal;
if (body.dailyWordCount !== undefined) data.dailyWordCount = body.dailyWordCount;
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;

View File

@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get('Stripe-Signature') as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error: any) {
return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 });
}
const session = event.data.object as Stripe.Checkout.Session;
if (event.type === 'checkout.session.completed') {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
) as any;
if (!session?.metadata?.userId) {
return new NextResponse('User id is required', { status: 400 });
}
await prisma.user.update({
where: {
id: session.metadata.userId,
},
data: {
stripeSubscriptionId: subscription.id,
stripeCustomerId: subscription.customer as string,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
planId: session.metadata.planId,
},
});
}
if (event.type === 'invoice.payment_succeeded') {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
) as any;
await prisma.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
},
});
}
return new NextResponse(null, { status: 200 });
}

View File

@@ -1,67 +0,0 @@
'use client';
import Checkout from '@/components/Checkout';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
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
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

@@ -29,9 +29,29 @@ export default function PricingPage() {
<Pricing
plans={plans}
isLoading={isLoading}
currentPlan={user?.subscription.plan || 'free'}
currentPlan={user?.planId || 'free'}
onBack={() => router.push(user ? '/dashboard' : '/')}
onSelectPlan={(id) => router.push(user ? `/checkout?plan=${id}` : '/login')}
onSelectPlan={async (id) => {
if (!user) {
router.push('/login');
return;
}
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId: id })
});
const data = await response.json();
if (data.url) {
window.location.href = data.url;
}
} catch (err) {
console.error('Checkout error:', err);
}
}}
/>
);
}

View File

@@ -26,7 +26,7 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
const pathname = usePathname();
const projectId = params.id as string;
const { user, logout, incrementUsage, loading: authLoading } = useAuthContext();
const { user, logout, incrementUsage, refreshProfile, loading: authLoading } = useAuthContext();
const hasEverLoaded = useRef(false);
const {
projects, setCurrentProjectId,
@@ -115,11 +115,12 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
createEntity: (type, data) => createEntity(projectId, type, data),
updateEntity: (entityId, data) => updateEntity(projectId, entityId, data),
deleteEntity: (entityId) => deleteEntity(projectId, entityId),
createIdea: (projectId, data) => createIdea(projectId, data),
updateIdea: (projectId, ideaId, data) => updateIdea(projectId, ideaId, data),
deleteIdea: (projectId, ideaId) => deleteIdea(projectId, ideaId),
createIdea: (id, data) => createIdea(id, data),
updateIdea: (id, ideaId, data) => updateIdea(id, ideaId, data),
deleteIdea: (id, ideaId) => deleteIdea(id, ideaId),
deleteProject: () => deleteProject(projectId),
incrementUsage,
refreshProfile,
}}>
<EditorShell
project={project}
@@ -131,7 +132,13 @@ export default function ProjectLayout({ children }: { children: React.ReactNode
onViewModeChange={handleViewModeChange}
onChapterSelect={(id) => { setCurrentChapterId(id); router.push(`/project/${projectId}`); }}
onUpdateProject={(updates) => updateProject(projectId, updates)}
onUpdateChapter={(chapterId, data) => updateChapter(projectId, chapterId, data)}
onUpdateChapter={async (chapterId, data) => {
await updateChapter(projectId, chapterId, data);
// If content was updated, refresh profile to sync "mots du jour"
if (data.content !== undefined) {
refreshProfile();
}
}}
onReorderChapters={(chapters) => reorderChapters(projectId, chapters)}
onAddChapter={async () => {
const id = await addChapter(projectId, {});

View File

@@ -24,7 +24,7 @@ const DEFAULT_SETTINGS: BookSettings = {
themes: ''
};
const BookSettingsComponent: React.FC<BookSettingsProps> = ({ project, onUpdate, onDeleteProject }) => {
const BookSettingsComponent: React.FC<BookSettingsProps> = React.memo(({ project, onUpdate, onDeleteProject }) => {
const { t } = useLanguage();
// Local state for all editable fields to prevent excessive API calls
@@ -288,6 +288,6 @@ const BookSettingsComponent: React.FC<BookSettingsProps> = ({ project, onUpdate,
</div>
</div>
);
};
});
export default BookSettingsComponent;

View File

@@ -1,68 +0,0 @@
'use client';
import React, { useState } from 'react';
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> = ({ plan, onComplete, onCancel }) => {
const [loading, setLoading] = useState(false);
const { t } = useLanguage();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setTimeout(() => {
onComplete();
}, 2000);
};
return (
<div className="min-h-screen bg-[#eef2ff] flex items-center justify-center p-8">
<div className="bg-white rounded-3xl shadow-2xl flex flex-col md:flex-row max-w-4xl w-full overflow-hidden animate-in fade-in slide-in-from-bottom-10 duration-500">
<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>{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">{(plan.price * 1.2).toFixed(2)}</span></div>
</div>
</div>
<div className="flex-1 p-8 md:p-12">
<h2 className="text-2xl font-black text-slate-900 mb-8 text-center">{t('checkout.secure_payment')}</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-xs font-black text-slate-500 uppercase tracking-widest mb-2">{t('checkout.card_number')}</label>
<div className="relative">
<input type="text" placeholder="4242 4242 4242 4242" className="w-full bg-[#eef2ff] border border-indigo-100 p-4 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-bold" />
<CreditCard className="absolute right-4 top-4 text-slate-400" />
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<input type="text" placeholder="MM / YY" className="w-full bg-[#eef2ff] border border-indigo-100 p-4 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-bold" />
<input type="text" placeholder="CVC" className="w-full bg-[#eef2ff] border border-indigo-100 p-4 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-bold" />
</div>
<button
disabled={loading}
className="w-full bg-blue-600 text-white py-5 rounded-2xl font-black text-lg hover:bg-blue-700 transition-all shadow-xl shadow-blue-200 flex items-center justify-center gap-3"
>
{loading ? <Loader2 className="animate-spin" /> : <>{t('checkout.confirm_payment')} <ArrowRight size={20} /></>}
</button>
<div className="flex items-center justify-center gap-2 text-[10px] text-slate-400 font-bold uppercase">
<Shield size={12} /> {t('checkout.ssl_encryption')}
</div>
</form>
</div>
</div>
</div>
);
};
export default Checkout;

View File

@@ -74,7 +74,7 @@ const Dashboard: React.FC<DashboardProps> = ({ user, projects, onSelect, onCreat
<div className="bg-indigo-100 p-3 rounded-2xl text-indigo-600"><Target size={24} /></div>
<div>
<p className="text-xs font-bold text-theme-muted uppercase tracking-wider">{t('dashboard.daily_goal')}</p>
<p className="text-2xl font-black text-theme-text">{user.preferences.dailyWordGoal} {t('dashboard.words')}</p>
<p className="text-2xl font-black text-theme-text">{user.stats.dailyWordCount} / {user.preferences.dailyWordGoal} {t('dashboard.words')}</p>
</div>
</div>
</div>

View File

@@ -29,7 +29,7 @@ const STATUS_LABELS: Record<IdeaStatus, string> = {
const MAX_DESCRIPTION_LENGTH = 500;
const IdeaBoard: React.FC<IdeaBoardProps> = ({ projectId, ideas, onCreate, onUpdateIdea, onDelete }) => {
const IdeaBoard: React.FC<IdeaBoardProps> = React.memo(({ projectId, ideas, onCreate, onUpdateIdea, onDelete }) => {
const { t } = useLanguage();
const [newIdeaTitle, setNewIdeaTitle] = useState('');
const [newIdeaCategory, setNewIdeaCategory] = useState<IdeaCategory>('plot');
@@ -465,6 +465,6 @@ const IdeaBoard: React.FC<IdeaBoardProps> = ({ projectId, ideas, onCreate, onUpd
</div>
);
};
});
export default IdeaBoard;

View File

@@ -407,6 +407,32 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
font-style: italic;
cursor: text;
}
.editor-content h1 {
font-size: 2.25rem;
font-weight: 800;
margin-top: 1.5rem;
margin-bottom: 1rem;
line-height: 1.2;
display: block;
}
.editor-content h2 {
font-size: 1.875rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
line-height: 1.3;
display: block;
}
.editor-content ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.editor-content ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-bottom: 1rem;
}
`}</style>
{/* Toolbar */}
@@ -415,8 +441,18 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
<ToolbarButton icon={Italic} cmd="italic" label="Italique" />
<ToolbarButton icon={Underline} cmd="underline" label="Souligné" />
<div className="w-px h-6 bg-slate-300 mx-1" />
<ToolbarButton icon={Heading1} cmd="formatBlock" arg="H1" label="Titre 1" />
<ToolbarButton icon={Heading2} cmd="formatBlock" arg="H2" label="Titre 2" />
<ToolbarButton icon={Heading1} cmd="formatBlock" arg="<h1>" label="Titre 1" />
<ToolbarButton icon={Heading2} cmd="formatBlock" arg="<h2>" label="Titre 2" />
<button
onMouseDown={(e) => {
e.preventDefault();
execCommand('formatBlock', '<p>');
}}
className="p-1.5 rounded transition-colors text-slate-500 hover:text-slate-800 hover:bg-slate-200 font-bold text-sm px-2"
title="Paragraphe"
>
P
</button>
<div className="w-px h-6 bg-slate-300 mx-1" />
<ToolbarButton icon={AlignLeft} cmd="justifyLeft" label="Aligner à gauche" />
<ToolbarButton icon={AlignCenter} cmd="justifyCenter" label="Centrer" />
@@ -665,6 +701,120 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(({
<div className="h-px bg-slate-100 my-1" />
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
Mise en forme
</div>
<div className="flex px-1 gap-1 border-b border-slate-50 pb-1 mb-1">
<button
onClick={() => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
}
execCommand('bold');
setContextMenu(null);
}}
className="flex-1 p-2 flex items-center justify-center hover:bg-slate-100 rounded text-slate-700"
title="Gras"
>
<Bold size={16} />
</button>
<button
onClick={() => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
}
execCommand('italic');
setContextMenu(null);
}}
className="flex-1 p-2 flex items-center justify-center hover:bg-slate-100 rounded text-slate-700"
title="Italique"
>
<Italic size={16} />
</button>
<button
onClick={() => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
}
execCommand('underline');
setContextMenu(null);
}}
className="flex-1 p-2 flex items-center justify-center hover:bg-slate-100 rounded text-slate-700"
title="Souligné"
>
<Underline size={16} />
</button>
</div>
<button
onClick={() => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
}
execCommand('formatBlock', '<h1>');
setContextMenu(null);
}}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 text-left transition-colors"
>
<Heading1 size={14} /> Titre 1
</button>
<button
onClick={() => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
}
execCommand('formatBlock', '<h2>');
setContextMenu(null);
}}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 text-left transition-colors"
>
<Heading2 size={14} /> Titre 2
</button>
<button
onClick={() => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
}
execCommand('formatBlock', '<p>');
setContextMenu(null);
}}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 text-left transition-colors"
>
<span className="font-bold text-xs w-[14px] text-center">P</span> Paragraphe (Normal)
</button>
<button
onClick={() => {
if (savedRange.current) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRange.current);
}
execCommand('insertUnorderedList');
setContextMenu(null);
}}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 text-left transition-colors"
>
<List size={14} /> Liste à puces
</button>
<div className="h-px bg-slate-100 my-1" />
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
Édition
</div>

View File

@@ -278,7 +278,7 @@ interface SuggestionState {
filteredEntities: Entity[];
}
const StoryWorkflow: React.FC<StoryWorkflowProps> = ({ data, onUpdate, entities, onNavigateToEntity }) => {
const StoryWorkflow: React.FC<StoryWorkflowProps> = React.memo(({ data, onUpdate, entities, onNavigateToEntity }) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
@@ -746,6 +746,6 @@ const StoryWorkflow: React.FC<StoryWorkflowProps> = ({ data, onUpdate, entities,
)}
</div>
);
};
});
export default StoryWorkflow;

View File

@@ -32,7 +32,7 @@ const DEFAULT_CHAR_ATTRIBUTES: CharacterAttributes = {
behavioralQuirk: ''
};
const WorldBuilder: React.FC<WorldBuilderProps> = ({ entities, onCreate, onUpdate, onDelete, templates, onUpdateTemplates, initialSelectedId }) => {
const WorldBuilder = React.memo(({ entities, onCreate, onUpdate, onDelete, templates, onUpdateTemplates, initialSelectedId }: WorldBuilderProps) => {
const { t } = useLanguage();
const [editingId, setEditingId] = useState<string | null>(null);
const [tempEntity, setTempEntity] = useState<Entity | null>(null);
@@ -765,6 +765,6 @@ const WorldBuilder: React.FC<WorldBuilderProps> = ({ entities, onCreate, onUpdat
</div>
</div>
);
};
});
export default WorldBuilder;

View File

@@ -162,11 +162,15 @@ const EditorShell: React.FC<EditorShellProps> = (props) => {
))}
<div className="mt-6 px-4 py-2 text-xs font-semibold text-slate-500 uppercase">{t('nav.tools')}</div>
<button onClick={() => props.onViewModeChange('write')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'write' ? 'bg-blue-900 text-white' : 'hover:bg-slate-800'}`}><FileText size={16} /> {t('sidebar.write')}</button>
<button onClick={() => props.onViewModeChange('world_building')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'world_building' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Globe size={16} /> {t('sidebar.world_building')}</button>
<button onClick={() => props.onViewModeChange('workflow')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'workflow' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><GitGraph size={16} /> {t('sidebar.workflow')}</button>
<button onClick={() => props.onViewModeChange('ideas')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'ideas' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Lightbulb size={16} /> {t('sidebar.ideas')}</button>
<button onClick={() => props.onViewModeChange('settings')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'settings' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Settings size={16} /> {t('sidebar.settings')}</button>
{React.useMemo(() => (
<>
<button onClick={() => props.onViewModeChange('write')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'write' ? 'bg-blue-900 text-white' : 'hover:bg-slate-800'}`}><FileText size={16} /> {t('sidebar.write')}</button>
<button onClick={() => props.onViewModeChange('world_building')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'world_building' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Globe size={16} /> {t('sidebar.world_building')}</button>
<button onClick={() => props.onViewModeChange('workflow')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'workflow' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><GitGraph size={16} /> {t('sidebar.workflow')}</button>
<button onClick={() => props.onViewModeChange('ideas')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'ideas' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Lightbulb size={16} /> {t('sidebar.ideas')}</button>
<button onClick={() => props.onViewModeChange('settings')} className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 ${viewMode === 'settings' ? 'bg-indigo-900 text-white' : 'hover:bg-slate-800'}`}><Settings size={16} /> {t('sidebar.settings')}</button>
</>
), [viewMode, t, props])}
</div>
<div className="p-4 border-t border-slate-800">

View File

@@ -44,6 +44,7 @@ export const useAuth = () => {
preferences: { theme: 'light', dailyWordGoal: dbUser.dailyWordGoal || 500, language: 'fr' },
stats: {
totalWordsWritten: dbUser.totalWords || 0,
dailyWordCount: dbUser.dailyWordCount || 0,
writingStreak: dbUser.writingStreak || 0,
lastWriteDate: dbUser.lastWriteDate ? new Date(dbUser.lastWriteDate).getTime() : 0,
},
@@ -59,7 +60,7 @@ export const useAuth = () => {
subscription: { plan: 'free', startDate: Date.now(), status: 'active' },
usage: { aiActionsCurrent: 0, aiActionsLimit: 100, projectsLimit: 3 },
preferences: { theme: 'light', dailyWordGoal: 500, language: 'fr' },
stats: { totalWordsWritten: 0, writingStreak: 0, lastWriteDate: 0 },
stats: { totalWordsWritten: 0, dailyWordCount: 0, writingStreak: 0, lastWriteDate: 0 },
});
});
} else if (status === 'unauthenticated') {
@@ -133,5 +134,50 @@ export const useAuth = () => {
}
}, [user]);
return { user, login, signup, logout, incrementUsage, updateProfile, loading };
const refreshProfile = useCallback(async () => {
if (!session?.user?.id) return;
try {
const res = await fetch('/api/user/profile', { cache: 'no-store' });
const dbUser = await res.json();
const planId = dbUser.plan || 'free';
const planDetails = dbUser.planDetails || {
id: 'free',
displayName: 'Gratuit',
maxAiActions: 100,
maxProjects: 3
};
setUser({
id: dbUser.id,
email: dbUser.email,
name: dbUser.name || 'User',
avatar: dbUser.avatar,
bio: dbUser.bio,
subscription: {
plan: planId,
planDetails: planDetails,
startDate: new Date(dbUser.createdAt).getTime(),
status: 'active'
},
usage: {
aiActionsCurrent: dbUser.aiActionsUsed || 0,
aiActionsLimit: planDetails.maxAiActions,
projectsLimit: planDetails.maxProjects,
},
preferences: { theme: 'light', dailyWordGoal: dbUser.dailyWordGoal || 500, language: 'fr' },
stats: {
totalWordsWritten: dbUser.totalWords || 0,
dailyWordCount: dbUser.dailyWordCount || 0,
writingStreak: dbUser.writingStreak || 0,
lastWriteDate: dbUser.lastWriteDate ? new Date(dbUser.lastWriteDate).getTime() : 0,
},
});
} catch (err) {
console.error('Failed to refresh user profile:', err);
}
}, [session]);
return { user, login, signup, logout, incrementUsage, updateProfile, refreshProfile, loading };
};

View File

@@ -4,12 +4,12 @@ import { prisma } from './prisma';
* Updates the user's writing streak based on their last write date.
* Should be called whenever a user performs a writing action (e.g., saving a chapter).
*/
export async function updateWritingStreak(userId: string) {
export async function updateWritingStreak(userId: string, wordDelta: number = 0) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { writingStreak: true, lastWriteDate: true }
});
select: { writingStreak: true, lastWriteDate: true, dailyWordCount: true }
}) as any;
if (!user) return;
@@ -18,6 +18,7 @@ export async function updateWritingStreak(userId: string) {
let newStreak = user.writingStreak;
let lastWrite = user.lastWriteDate ? new Date(user.lastWriteDate) : null;
let newDailyCount = user.dailyWordCount || 0;
if (lastWrite) {
const lastWriteDay = new Date(Date.UTC(lastWrite.getFullYear(), lastWrite.getMonth(), lastWrite.getDate()));
@@ -27,28 +28,41 @@ export async function updateWritingStreak(userId: string) {
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
// Already wrote today, do nothing but update the timestamp
// Already wrote today, increment daily count
newDailyCount += wordDelta;
} else if (diffDays === 1) {
// Wrote yesterday, increment streak
// Wrote yesterday, increment streak, reset daily count to the new delta
newStreak += 1;
newDailyCount = wordDelta;
} else {
// Missed a day (or more), reset streak to 1
// Missed a day (or more), reset streak to 1, reset daily count to the new delta
newStreak = 1;
newDailyCount = wordDelta;
}
} else {
// First time writing
newStreak = 1;
newDailyCount = wordDelta;
}
// Update database
await prisma.user.update({
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
writingStreak: newStreak,
lastWriteDate: now,
dailyWordCount: newDailyCount
},
select: {
writingStreak: true,
dailyWordCount: true,
lastWriteDate: true
}
});
return updatedUser;
} catch (error) {
console.error('Failed to update writing streak:', error);
return null;
}
}

10
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,10 @@
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not defined');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2026-02-25.clover', // Update to match types
typescript: true,
});

View File

@@ -173,6 +173,7 @@ export interface UserPreferences {
export interface UserStats {
totalWordsWritten: number;
dailyWordCount: number;
writingStreak: number;
lastWriteDate: number;
}

View File

@@ -28,13 +28,19 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil
localStorage.setItem('pluume_language', lang);
};
const t = (key: TranslationKey): string => {
const t = React.useCallback((key: TranslationKey): string => {
if (!isMounted) return translations.fr[key] || key; // SSR fallback
return translations[language][key] || key;
};
}, [isMounted, language]);
const contextValue = React.useMemo(() => ({
language,
setLanguage,
t
}), [language, t]);
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
<LanguageContext.Provider value={contextValue}>
{children}
</LanguageContext.Provider>
);

View File

@@ -19,6 +19,7 @@ interface ProjectContextType {
deleteIdea: (projectId: string, ideaId: string) => Promise<void>;
deleteProject: () => Promise<void>;
incrementUsage: () => void;
refreshProfile: () => Promise<void>;
}
const ProjectContext = createContext<ProjectContextType | null>(null);