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:
3
.env
3
.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
|
||||
GEMINI_API_KEY=AIzaSyBjMxaRq4psBbvtdks0iYGkv-r9midKSh4
|
||||
STRIPE_SECRET_KEY=sk_test_51T8SBN2WIo50AZ0wy5YbNVscHHw6y10z1aB0Ho54iqHHJ4ROYAQRMQEi42sgI8dknnGsHOxgo5tpw9UZA9QE2xIR00R5WD9BjB
|
||||
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
BIN
.next/dev/cache/turbopack/23c46498/CURRENT
vendored
Binary file not shown.
6
.next/dev/cache/turbopack/23c46498/LOG
vendored
6
.next/dev/cache/turbopack/23c46498/LOG
vendored
@@ -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
20
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 });
|
||||
|
||||
76
src/app/api/checkout/route.ts
Normal file
76
src/app/api/checkout/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
70
src/app/api/webhooks/stripe/route.ts
Normal file
70
src/app/api/webhooks/stripe/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
10
src/lib/stripe.ts
Normal 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,
|
||||
});
|
||||
@@ -173,6 +173,7 @@ export interface UserPreferences {
|
||||
|
||||
export interface UserStats {
|
||||
totalWordsWritten: number;
|
||||
dailyWordCount: number;
|
||||
writingStreak: number;
|
||||
lastWriteDate: number;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user