diff --git a/.env b/.env
index a9feb3f..7fa7be0 100644
--- a/.env
+++ b/.env
@@ -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
\ No newline at end of file
+GEMINI_API_KEY=AIzaSyBjMxaRq4psBbvtdks0iYGkv-r9midKSh4
+STRIPE_SECRET_KEY=sk_test_51T8SBN2WIo50AZ0wy5YbNVscHHw6y10z1aB0Ho54iqHHJ4ROYAQRMQEi42sgI8dknnGsHOxgo5tpw9UZA9QE2xIR00R5WD9BjB
\ No newline at end of file
diff --git a/.next/dev/cache/turbopack/23c46498/CURRENT b/.next/dev/cache/turbopack/23c46498/CURRENT
index 8ddf084..46683af 100644
Binary files a/.next/dev/cache/turbopack/23c46498/CURRENT and b/.next/dev/cache/turbopack/23c46498/CURRENT differ
diff --git a/.next/dev/cache/turbopack/23c46498/LOG b/.next/dev/cache/turbopack/23c46498/LOG
index 9d93da8..033975a 100644
--- a/.next/dev/cache/turbopack/23c46498/LOG
+++ b/.next/dev/cache/turbopack/23c46498/LOG
@@ -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)
diff --git a/package-lock.json b/package-lock.json
index 4e8fd07..f7521be 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index deaa17b..5157e1d 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 56e8c21..267f38b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
diff --git a/src/app/api/chapters/[id]/route.ts b/src/app/api/chapters/[id]/route.ts
index 1f06f66..fd3e58d 100644
--- a/src/app/api/chapters/[id]/route.ts
+++ b/src/app/api/chapters/[id]/route.ts
@@ -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]
diff --git a/src/app/api/chapters/route.ts b/src/app/api/chapters/route.ts
index 70e183a..63c9f14 100644
--- a/src/app/api/chapters/route.ts
+++ b/src/app/api/chapters/route.ts
@@ -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 });
diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts
new file mode 100644
index 0000000..900bbee
--- /dev/null
+++ b/src/app/api/checkout/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts
index d29d4ce..451a71f 100644
--- a/src/app/api/user/profile/route.ts
+++ b/src/app/api/user/profile/route.ts
@@ -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;
diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts
new file mode 100644
index 0000000..b3190c2
--- /dev/null
+++ b/src/app/api/webhooks/stripe/route.ts
@@ -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 });
+}
diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx
deleted file mode 100644
index 1edd52d..0000000
--- a/src/app/checkout/page.tsx
+++ /dev/null
@@ -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
Chargement...
;
-
- return (
- router.push('/pricing')}
- />
- );
-}
-
-export default function CheckoutPage() {
- return (
- Chargement...}>
-
-
- );
-}
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx
index 31b3acc..d44fd88 100644
--- a/src/app/pricing/page.tsx
+++ b/src/app/pricing/page.tsx
@@ -29,9 +29,29 @@ export default function PricingPage() {
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);
+ }
+ }}
/>
);
}
diff --git a/src/app/project/[id]/layout.tsx b/src/app/project/[id]/layout.tsx
index 4ac8741..c7e4acb 100644
--- a/src/app/project/[id]/layout.tsx
+++ b/src/app/project/[id]/layout.tsx
@@ -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,
}}>
{ 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, {});
diff --git a/src/components/BookSettings.tsx b/src/components/BookSettings.tsx
index 0fc2451..83ff87c 100644
--- a/src/components/BookSettings.tsx
+++ b/src/components/BookSettings.tsx
@@ -24,7 +24,7 @@ const DEFAULT_SETTINGS: BookSettings = {
themes: ''
};
-const BookSettingsComponent: React.FC = ({ project, onUpdate, onDeleteProject }) => {
+const BookSettingsComponent: React.FC = 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 = ({ project, onUpdate,
);
-};
+});
export default BookSettingsComponent;
\ No newline at end of file
diff --git a/src/components/Checkout.tsx b/src/components/Checkout.tsx
deleted file mode 100644
index 1354d8b..0000000
--- a/src/components/Checkout.tsx
+++ /dev/null
@@ -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 = ({ plan, onComplete, onCancel }) => {
- const [loading, setLoading] = useState(false);
- const { t } = useLanguage();
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- setLoading(true);
- setTimeout(() => {
- onComplete();
- }, 2000);
- };
-
- return (
-
-
-
-
{t('checkout.order')}
-
-
{plan.displayName}{plan.price.toFixed(2)}€
-
{t('checkout.vat')} (20%){(plan.price * 0.2).toFixed(2)}€
-
-
{t('checkout.total')}{(plan.price * 1.2).toFixed(2)}€
-
-
-
-
{t('checkout.secure_payment')}
-
-
-
-
- );
-};
-
-export default Checkout;
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
index 789366b..90001c3 100644
--- a/src/components/Dashboard.tsx
+++ b/src/components/Dashboard.tsx
@@ -74,7 +74,7 @@ const Dashboard: React.FC = ({ user, projects, onSelect, onCreat
{t('dashboard.daily_goal')}
-
{user.preferences.dailyWordGoal} {t('dashboard.words')}
+
{user.stats.dailyWordCount} / {user.preferences.dailyWordGoal} {t('dashboard.words')}
diff --git a/src/components/IdeaBoard.tsx b/src/components/IdeaBoard.tsx
index a9e8283..2df63cb 100644
--- a/src/components/IdeaBoard.tsx
+++ b/src/components/IdeaBoard.tsx
@@ -29,7 +29,7 @@ const STATUS_LABELS: Record = {
const MAX_DESCRIPTION_LENGTH = 500;
-const IdeaBoard: React.FC = ({ projectId, ideas, onCreate, onUpdateIdea, onDelete }) => {
+const IdeaBoard: React.FC = React.memo(({ projectId, ideas, onCreate, onUpdateIdea, onDelete }) => {
const { t } = useLanguage();
const [newIdeaTitle, setNewIdeaTitle] = useState('');
const [newIdeaCategory, setNewIdeaCategory] = useState('plot');
@@ -465,6 +465,6 @@ const IdeaBoard: React.FC = ({ projectId, ideas, onCreate, onUpd
);
-};
+});
export default IdeaBoard;
\ No newline at end of file
diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx
index 5425124..6f12223 100644
--- a/src/components/RichTextEditor.tsx
+++ b/src/components/RichTextEditor.tsx
@@ -407,6 +407,32 @@ const RichTextEditor = forwardRef(({
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;
+ }
`}
{/* Toolbar */}
@@ -415,8 +441,18 @@ const RichTextEditor = forwardRef(({
-
-
+
+
+
@@ -665,6 +701,120 @@ const RichTextEditor = forwardRef(({
+
+ Mise en forme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Édition
diff --git a/src/components/StoryWorkflow.tsx b/src/components/StoryWorkflow.tsx
index 0c51e97..3a3439c 100644
--- a/src/components/StoryWorkflow.tsx
+++ b/src/components/StoryWorkflow.tsx
@@ -278,7 +278,7 @@ interface SuggestionState {
filteredEntities: Entity[];
}
-const StoryWorkflow: React.FC = ({ data, onUpdate, entities, onNavigateToEntity }) => {
+const StoryWorkflow: React.FC = React.memo(({ data, onUpdate, entities, onNavigateToEntity }) => {
const { t } = useLanguage();
const containerRef = useRef(null);
const rafRef = useRef(null);
@@ -746,6 +746,6 @@ const StoryWorkflow: React.FC = ({ data, onUpdate, entities,
)}
);
-};
+});
export default StoryWorkflow;
diff --git a/src/components/WorldBuilder.tsx b/src/components/WorldBuilder.tsx
index d62314b..89a6c87 100644
--- a/src/components/WorldBuilder.tsx
+++ b/src/components/WorldBuilder.tsx
@@ -32,7 +32,7 @@ const DEFAULT_CHAR_ATTRIBUTES: CharacterAttributes = {
behavioralQuirk: ''
};
-const WorldBuilder: React.FC = ({ 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(null);
const [tempEntity, setTempEntity] = useState(null);
@@ -765,6 +765,6 @@ const WorldBuilder: React.FC = ({ entities, onCreate, onUpdat
);
-};
+});
export default WorldBuilder;
\ No newline at end of file
diff --git a/src/components/layout/EditorShell.tsx b/src/components/layout/EditorShell.tsx
index d2b7c35..8fa702a 100644
--- a/src/components/layout/EditorShell.tsx
+++ b/src/components/layout/EditorShell.tsx
@@ -162,11 +162,15 @@ const EditorShell: React.FC = (props) => {
))}
{t('nav.tools')}
-
-
-
-
-
+ {React.useMemo(() => (
+ <>
+
+
+
+
+
+ >
+ ), [viewMode, t, props])}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
index 76e186d..f006cab 100644
--- a/src/hooks/useAuth.ts
+++ b/src/hooks/useAuth.ts
@@ -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 };
};
diff --git a/src/lib/streak.ts b/src/lib/streak.ts
index edbf642..27b4ae3 100644
--- a/src/lib/streak.ts
+++ b/src/lib/streak.ts
@@ -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;
}
}
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
new file mode 100644
index 0000000..afdfa64
--- /dev/null
+++ b/src/lib/stripe.ts
@@ -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,
+});
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 20057a5..1b66a49 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -173,6 +173,7 @@ export interface UserPreferences {
export interface UserStats {
totalWordsWritten: number;
+ dailyWordCount: number;
writingStreak: number;
lastWriteDate: number;
}
diff --git a/src/providers/LanguageProvider.tsx b/src/providers/LanguageProvider.tsx
index c23c2a7..61409a7 100644
--- a/src/providers/LanguageProvider.tsx
+++ b/src/providers/LanguageProvider.tsx
@@ -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 (
-
+
{children}
);
diff --git a/src/providers/ProjectProvider.tsx b/src/providers/ProjectProvider.tsx
index 13d76af..d07a0d3 100644
--- a/src/providers/ProjectProvider.tsx
+++ b/src/providers/ProjectProvider.tsx
@@ -19,6 +19,7 @@ interface ProjectContextType {
deleteIdea: (projectId: string, ideaId: string) => Promise;
deleteProject: () => Promise;
incrementUsage: () => void;
+ refreshProfile: () => Promise;
}
const ProjectContext = createContext(null);