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')}

-
-
- -
- - -
-
-
- - -
- -
- {t('checkout.ssl_encryption')} -
-
-
-
-
- ); -}; - -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);