feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup

- Restructured docker-compose for Nginx Proxy Manager (no custom nginx)
- Added domain wordly.art configuration
- Added Prometheus + Grafana monitoring stack with pre-configured dashboards
- Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation)
- Added alert rules for backend, system, and Docker metrics
- Updated deployment guide for NPM + IONOS DNS homelab setup
- Added marketing plan document
- PDF translator and watermark support
- Enhanced middleware, routes, and translator modules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -4,67 +4,59 @@ import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { useI18n, type Locale } from '@/lib/i18n';
import { useI18n, type Locale, formatDate } from '@/lib/i18n';
import {
User, Mail, Calendar, Crown, Zap, Sparkles, Building2, Rocket,
FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
BadgeCheck, ShieldAlert, Info, Globe,
BadgeCheck, ShieldAlert, Info, Globe, Settings, Palette, Trash2, Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { languages } from '@/lib/api';
import { useTranslationStore } from '@/lib/store';
import { cn } from '@/lib/utils';
/* ─────────────── helpers ─────────────── */
/* ── helpers ──────────────────────────────────────────────────── */
const PLAN_ICONS: Record<string, React.ElementType> = {
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
};
const PLAN_COLORS: Record<string, { badge: string; gradient: string; ring: string }> = {
free: { badge: 'bg-muted text-muted-foreground border border-border', gradient: 'from-slate-600 to-slate-700', ring: 'ring-slate-500/30' },
free: { badge: 'bg-muted text-muted-foreground border border-border', gradient: 'from-slate-600 to-slate-700', ring: 'ring-slate-500/30' },
starter: { badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', gradient: 'from-blue-600 to-blue-700', ring: 'ring-blue-500/30' },
pro: { badge: 'bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-200', gradient: 'from-violet-600 to-violet-700', ring: 'ring-violet-500/30' },
business: { badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-200', gradient: 'from-emerald-600 to-emerald-700', ring: 'ring-emerald-500/30' },
enterprise: { badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-200', gradient: 'from-amber-600 to-amber-700', ring: 'ring-amber-500/30' },
};
const PLAN_LABELS: Record<string, string> = {
free: 'Gratuit', starter: 'Starter', pro: 'Pro', business: 'Business', enterprise: 'Entreprise',
};
const PLAN_PRICES: Record<string, number> = {
free: 0, starter: 9, pro: 19, business: 49,
free: 'profile.plan.free', starter: 'profile.plan.starter', pro: 'profile.plan.pro', business: 'profile.plan.business', enterprise: 'profile.plan.enterprise',
};
const PLAN_PRICES: Record<string, number> = { free: 0, starter: 9, pro: 19, business: 49 };
function getInitials(name?: string) {
if (!name) return '??';
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
}
function pct(used: number, limit: number) {
if (limit === -1 || limit === 0) return 0;
return Math.min(100, Math.round((used / limit) * 100));
}
function fmtLimit(val: number) {
return val === -1 ? '∞' : String(val);
}
function nextResetDate() {
function fmtLimit(val: number) { return val === -1 ? '∞' : String(val); }
function nextResetDate(locale: Locale) {
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return next.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
return formatDate(next, locale, { day: 'numeric', month: 'long' });
}
interface UsageBarProps {
label: string;
used: number;
limit: number;
icon: React.ReactNode;
}
function UsageBar({ label, used, limit, icon }: UsageBarProps) {
function UsageBar({ label, used, limit, icon }: { label: string; used: number; limit: number; icon: React.ReactNode }) {
const p = pct(used, limit);
const isUnlimited = limit === -1;
return (
@@ -81,13 +73,7 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
</div>
{!isUnlimited && (
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-700',
p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary'
)}
style={{ width: `${p}%` }}
/>
<div className={cn('h-full rounded-full transition-all duration-700', p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary')} style={{ width: `${p}%` }} />
</div>
)}
</div>
@@ -95,14 +81,26 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
}
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
{ value: 'en', label: 'English', flag: '🇬🇧' },
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
{ value: 'es', label: 'Español', flag: '🇪🇸' },
{ value: 'de', label: 'Deutsch', flag: '🇩🇪' },
{ value: 'pt', label: 'Português', flag: '🇧🇷' },
{ value: 'it', label: 'Italiano', flag: '🇮🇹' },
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
{ value: 'ja', label: '日本語', flag: '🇯🇵' },
{ value: 'ko', label: '한국어', flag: '🇰🇷' },
{ value: 'zh', label: '中文', flag: '🇨🇳' },
{ value: 'ar', label: 'العربية', flag: '🇸🇦' },
{ value: 'fa', label: 'فارسی', flag: '🇮🇷' },
];
/* ─────────────── Main component ─────────────── */
/* ── Main component ───────────────────────────────────────────── */
export default function ProfilePage() {
const router = useRouter();
const { locale, setLocale } = useI18n();
const { locale, setLocale, t } = useI18n();
const { settings, updateSettings } = useTranslationStore();
const [user, setUser] = useState<any>(null);
const [usage, setUsage] = useState<any>(null);
@@ -111,6 +109,8 @@ export default function ProfilePage() {
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [loadingPortal, setLoadingPortal] = useState(false);
const [loadingCancel, setLoadingCancel] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const authHeaders = { Authorization: `Bearer ${token}` };
@@ -124,14 +124,11 @@ export default function ProfilePage() {
]);
if (meRes.ok) { const j = await meRes.json(); setUser(j.data ?? j); }
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
} catch {
// ignore
} finally {
setLoading(false);
}
} catch { /* ignore */ } finally { setLoading(false); }
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
const handleBillingPortal = async () => {
setLoadingPortal(true);
@@ -140,32 +137,43 @@ export default function ProfilePage() {
const j = await res.json();
const url = j.data?.url ?? j.url;
if (url) window.open(url, '_blank');
else setStatusMsg({ type: 'err', text: 'Portail de facturation non disponible (Stripe non configuré).' });
} catch {
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'accès au portail.' });
} finally {
setLoadingPortal(false);
}
else setStatusMsg({ type: 'err', text: t('profile.subscription.billingUnavailable') });
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.billingError') }); }
finally { setLoadingPortal(false); }
};
const handleCancel = async () => {
if (!cancelConfirm) { setCancelConfirm(true); return; }
setLoadingCancel(true);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
method: 'POST', headers: authHeaders,
});
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, { method: 'POST', headers: authHeaders });
if (res.ok) {
setStatusMsg({ type: 'ok', text: 'Abonnement annulé. Vous conservez l\'accès jusqu\'à la fin de la période en cours.' });
setStatusMsg({ type: 'ok', text: t('profile.subscription.cancelSuccess') });
setCancelConfirm(false);
fetchData();
} else {
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'annulation. Contactez le support.' });
} else { setStatusMsg({ type: 'err', text: t('profile.subscription.cancelError') }); }
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.networkError') }); }
finally { setLoadingCancel(false); }
};
const handleSavePrefs = () => {
updateSettings({ defaultTargetLanguage: defaultLanguage });
};
const handleClearCache = async () => {
setIsClearing(true);
try {
localStorage.removeItem('translation-settings');
sessionStorage.clear();
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
} catch {
setStatusMsg({ type: 'err', text: 'Erreur réseau.' });
} finally {
setLoadingCancel(false);
await new Promise(resolve => setTimeout(resolve, 500));
window.location.reload();
} catch (error) {
console.error('Error clearing cache:', error);
setIsClearing(false);
}
};
@@ -178,7 +186,7 @@ export default function ProfilePage() {
}
const planId = user?.plan ?? user?.tier ?? 'free';
const planLabel = PLAN_LABELS[planId] ?? planId;
const planLabel = t(PLAN_LABELS[planId] ?? planId);
const planColors = PLAN_COLORS[planId] ?? PLAN_COLORS.free;
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
const planPrice = PLAN_PRICES[planId];
@@ -193,15 +201,15 @@ export default function ProfilePage() {
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8 max-w-3xl">
{/* ── Page title ── */}
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">Mon Profil</h1>
<p className="text-sm text-muted-foreground mt-1">Gérez votre compte, votre abonnement et votre utilisation.</p>
<h1 className="text-2xl font-bold text-foreground">{t('profile.header.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('profile.header.subtitle')}</p>
</div>
{/* ── Status message ── */}
{/* Status message */}
{statusMsg && (
<div className={cn(
'flex items-start gap-3 p-4 rounded-xl border text-sm',
@@ -209,73 +217,68 @@ export default function ProfilePage() {
? 'bg-success/10 border-success/30 text-success'
: 'bg-destructive/10 border-destructive/30 text-destructive'
)}>
{statusMsg.type === 'ok'
? <CheckCircle2 className="w-4 h-4 flex-shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
{statusMsg.type === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 shrink-0 mt-0.5" />}
<span className="flex-1">{statusMsg.text}</span>
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground"></button>
</div>
)}
{/* ── Two-column grid on desktop ── */}
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
{/* Tabs */}
<Tabs defaultValue="account" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="account" className="gap-1.5"><User className="size-3.5" /> {t('profile.tabs.account')}</TabsTrigger>
<TabsTrigger value="subscription" className="gap-1.5"><CreditCard className="size-3.5" /> {t('profile.tabs.subscription')}</TabsTrigger>
<TabsTrigger value="preferences" className="gap-1.5"><Settings className="size-3.5" /> {t('profile.tabs.preferences')}</TabsTrigger>
</TabsList>
{/* ── LEFT column ── */}
<div className="flex flex-col gap-6">
{/* Identity card */}
{/* ── Tab: Account ────────────────────────────────────── */}
<TabsContent value="account" className="space-y-6 pt-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-5">
<div className={cn(
'flex shrink-0 items-center justify-center w-16 h-16 rounded-2xl text-white font-bold text-xl ring-4',
`bg-gradient-to-br ${planColors.gradient}`,
planColors.ring
'flex shrink-0 items-center justify-center w-20 h-20 rounded-2xl text-white font-bold text-2xl ring-4',
`bg-gradient-to-br ${planColors.gradient}`, planColors.ring
)}>
{getInitials(user?.name)}
</div>
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-lg font-semibold text-foreground truncate">{user?.name || 'Utilisateur'}</h2>
<h2 className="text-xl font-semibold text-foreground truncate">{user?.name || t('profile.account.user')}</h2>
<Badge className={cn('text-xs font-medium', planColors.badge)}>
<PlanIcon className="w-3 h-3 mr-1" />
{planLabel}
<PlanIcon className="w-3 h-3 me-1" />{planLabel}
</Badge>
</div>
<div className="flex items-center gap-1.5 mt-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{user?.email}</span>
</div>
{user?.created_at && (
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Calendar className="w-3 h-3 shrink-0" />
<span>Membre depuis le {new Date(user.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}</span>
<span>{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Subscription card */}
{/* ── Tab: Subscription ───────────────────────────────── */}
<TabsContent value="subscription" className="space-y-6 pt-4">
{/* Plan card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CreditCard className="w-4 h-4 text-primary" />
Mon abonnement
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn('p-2.5 rounded-xl bg-gradient-to-br', planColors.gradient)}>
<PlanIcon className="w-5 h-5 text-white" />
<div className={cn('p-3 rounded-xl bg-gradient-to-br', planColors.gradient)}>
<PlanIcon className="w-6 h-6 text-white" />
</div>
<div>
<p className="font-semibold text-foreground">Forfait {planLabel}</p>
<p className="text-sm text-muted-foreground">
{isFreePlan ? 'Gratuit' : `${planPrice} €/mois`}
</p>
<p className="font-semibold text-foreground text-lg">{t('profile.plan.label')} {planLabel}</p>
<p className="text-sm text-muted-foreground">{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}</p>
</div>
</div>
{!isFreePlan && (
@@ -285,168 +288,89 @@ export default function ProfilePage() {
user?.subscription_status === 'active' ? 'text-success border-success/30 bg-success/10' :
'text-destructive border-destructive/30 bg-destructive/10'
)}>
{isCanceling ? 'Annulation en cours' :
user?.subscription_status === 'active' ? 'Actif' :
user?.subscription_status ?? 'Inconnu'}
{isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
</Badge>
)}
</div>
{!isFreePlan && user?.subscription_ends_at && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted text-sm">
<Info className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<Info className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">
{isCanceling
? `Accès jusqu'au ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`
: `Renouvellement le ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`}
? `${t('profile.subscription.accessUntil')} ${formatDate(new Date(user.subscription_ends_at), locale)}`
: `${t('profile.subscription.renewalOn')} ${formatDate(new Date(user.subscription_ends_at), locale)}`}
</span>
</div>
)}
<Separator />
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" className="gap-2">
<Link href="/pricing">
<TrendingUp className="w-3.5 h-3.5" />
{isFreePlan ? 'Passer à un forfait payant' : 'Changer de forfait'}
</Link>
</Button>
<Button asChild size="sm"><Link href="/pricing"><TrendingUp className="w-3.5 h-3.5 me-1.5" />{isFreePlan ? t('profile.subscription.upgradePlan') : t('profile.subscription.changePlan')}</Link></Button>
{!isFreePlan && (
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleBillingPortal}
disabled={loadingPortal}
>
{loadingPortal
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <ExternalLink className="w-3.5 h-3.5" />}
Gérer la facturation
<Button variant="outline" size="sm" onClick={handleBillingPortal} disabled={loadingPortal}>
{loadingPortal ? <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5 me-1.5" />}
{t('profile.subscription.manageBilling')}
</Button>
)}
</div>
</CardContent>
</Card>
{/* Danger zone */}
{!isFreePlan && !isCanceling && (
<Card className="border-red-900/30">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-red-600 dark:text-red-400">
<ShieldAlert className="w-4 h-4" />
Zone danger
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
L'annulation prend effet à la fin de votre période de facturation en cours. Vous conservez l'accès jusqu'à cette date.
</p>
{cancelConfirm && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
⚠️ Êtes-vous sûr(e) ? Cette action ne peut pas être annulée une fois la période terminée.
</div>
)}
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleCancel}
disabled={loadingCancel}
className="gap-2"
>
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{cancelConfirm ? "Confirmer l'annulation" : 'Annuler mon abonnement'}
</Button>
{cancelConfirm && (
<Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>
Non, garder
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* ── RIGHT column ── */}
<div className="flex flex-col gap-6">
{/* Usage card */}
{/* Usage */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<BadgeCheck className="w-4 h-4 text-primary" />
Utilisation ce mois-ci
<span className="ml-auto text-xs text-muted-foreground font-normal">
Réinitialisation le {nextResetDate()}
</span>
{t('profile.usage.title')}
<span className="ms-auto text-xs text-muted-foreground font-normal">{t('profile.usage.resetOn')} {nextResetDate(locale)}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<UsageBar
label="Documents traduits"
used={docsUsed}
limit={docsLimit}
icon={<FileText className="w-4 h-4" />}
/>
<UsageBar
label="Pages traduites"
used={pagesUsed}
limit={pagesLimit}
icon={<Layers className="w-4 h-4" />}
/>
<UsageBar label={t('profile.usage.documents')} used={docsUsed} limit={docsLimit} icon={<FileText className="w-4 h-4" />} />
<UsageBar label={t('profile.usage.pages')} used={pagesUsed} limit={pagesLimit} icon={<Layers className="w-4 h-4" />} />
{extraCredits > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-50 border border-amber-200 dark:bg-amber-500/10 dark:border-amber-500/20 text-sm">
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<span className="text-amber-700 dark:text-amber-300">
{extraCredits} crédit{extraCredits > 1 ? 's' : ''} supplémentaire{extraCredits > 1 ? 's' : ''} disponible{extraCredits > 1 ? 's' : ''}
</span>
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-amber-700 dark:text-amber-300">{extraCredits} {extraCredits > 1 ? t('profile.usage.extraCreditsPlural') : t('profile.usage.extraCredits')}</span>
</div>
)}
{usage?.upgrade_required && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-500/10 dark:border-red-500/20 text-sm">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-red-700 dark:text-red-300 font-medium">Quota atteint</p>
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">Passez à un forfait supérieur pour continuer à traduire.</p>
<p className="text-red-700 dark:text-red-300 font-medium">{t('profile.usage.quotaReached')}</p>
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">{t('profile.usage.quotaReachedDesc')}</p>
</div>
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 flex-shrink-0">
<Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link>
</Button>
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 shrink-0"><Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link></Button>
</div>
)}
{isFreePlan && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20 text-sm">
<Zap className="w-4 h-4 text-primary flex-shrink-0" />
<span className="text-primary flex-1">Débloquez plus de traductions avec un forfait payant.</span>
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 flex-shrink-0">
<Link href="/pricing">Voir les forfaits <ArrowRight className="w-3.5 h-3.5 ml-1" /></Link>
<Zap className="w-4 h-4 text-primary shrink-0" />
<span className="text-primary flex-1">{t('profile.usage.unlockMore')}</span>
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 shrink-0">
<Link href="/pricing">{t('profile.usage.viewPlans')} <ArrowRight className="w-3.5 h-3.5 ms-1" /></Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Features included */}
{/* Features */}
{user?.plan_limits?.features?.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
Inclus dans votre forfait
{t('profile.usage.includedInPlan')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{user.plan_limits.features.map((f: string, i: number) => (
<div key={i} className="flex items-start gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">{f}</span>
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
<span className="text-muted-foreground">{f.includes('.') ? t(f) : f}</span>
</div>
))}
</div>
@@ -454,43 +378,124 @@ export default function ProfilePage() {
</Card>
)}
{/* Preferences */}
{/* Danger zone */}
{!isFreePlan && !isCanceling && (
<Card className="border-red-900/30">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-red-600 dark:text-red-400">
<ShieldAlert className="w-4 h-4" /> {t('profile.danger.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{t('profile.danger.description')}
</p>
{cancelConfirm && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
{t('profile.danger.confirm')}
</div>
)}
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={loadingCancel}>
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" />}
{cancelConfirm ? t('profile.danger.confirmCancel') : t('profile.danger.cancelSubscription')}
</Button>
{cancelConfirm && <Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>{t('profile.danger.keep')}</Button>}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ── Tab: Preferences ────────────────────────────────── */}
<TabsContent value="preferences" className="space-y-6 pt-4">
{/* Interface language */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4 text-primary" />
Préférences
<Globe className="w-4 h-4 text-primary" /> {t('profile.prefs.interfaceLang')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-foreground">Langue de l'interface</p>
<p className="text-xs text-muted-foreground mt-0.5">Choisissez la langue d'affichage</p>
</div>
<div className="flex gap-2 shrink-0">
{UI_LANGUAGES.map((lang) => (
<button
key={lang.value}
onClick={() => setLocale(lang.value)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
locale === lang.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
)}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
</button>
<div className="flex flex-wrap gap-2">
{UI_LANGUAGES.map((lang) => (
<button
key={lang.value}
onClick={() => setLocale(lang.value)}
className={cn(
'flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
locale === lang.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
)}
>
<span>{lang.flag}</span><span>{lang.label}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-3">{t('profile.prefs.interfaceLangDesc')}</p>
</CardContent>
</Card>
{/* Default target language */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" /> {t('profile.prefs.defaultTargetLang')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
<SelectTrigger><SelectValue placeholder={t('profile.prefs.selectLanguage')} /></SelectTrigger>
<SelectContent className="max-h-[300px]">
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
<span className="flex items-center gap-2"><span>{lang.flag}</span><span>{lang.name}</span></span>
</SelectItem>
))}
</div>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('profile.prefs.defaultTargetLangDesc')}</p>
<div className="flex justify-end">
<Button size="sm" onClick={handleSavePrefs}>
<CheckCircle2 className="w-3.5 h-3.5 me-1.5" /> {t('profile.prefs.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Theme */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Palette className="w-4 h-4 text-primary" /> {t('profile.prefs.theme')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{t('profile.prefs.themeDesc')}</p>
<ThemeToggle />
</div>
</CardContent>
</Card>
{/* Cache */}
<Card className="border-border/60">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('profile.prefs.cache')}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">{t('profile.prefs.cacheDesc')}</p>
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('profile.prefs.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('profile.prefs.clearCache')}</>}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);