All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m24s
622 lines
33 KiB
TypeScript
622 lines
33 KiB
TypeScript
'use client';
|
||
|
||
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, 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, Settings, Palette, Trash2, Loader2,
|
||
Activity, ChevronDown,
|
||
} from 'lucide-react';
|
||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||
import { languages } from '@/lib/api';
|
||
import { useTranslationStore } from '@/lib/store';
|
||
import { cn } from '@/lib/utils';
|
||
import { useQueryClient } from '@tanstack/react-query';
|
||
|
||
/* ── helpers ──────────────────────────────────────────────────── */
|
||
const PLAN_ICONS: Record<string, React.ElementType> = {
|
||
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
|
||
};
|
||
const PLAN_LABELS: Record<string, string> = {
|
||
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();
|
||
}
|
||
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(locale: Locale) {
|
||
const now = new Date();
|
||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||
return formatDate(next, locale, { day: 'numeric', month: 'long' });
|
||
}
|
||
|
||
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
|
||
{ 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: '🇮🇷' },
|
||
];
|
||
|
||
function formatTitleWithItalic(title: string) {
|
||
const words = title.split(' ');
|
||
if (words.length > 1) {
|
||
return (
|
||
<>
|
||
{words.slice(0, -1).join(' ')} <span className="italic">{words[words.length - 1]}</span>
|
||
</>
|
||
);
|
||
}
|
||
return title;
|
||
}
|
||
|
||
/* ── Main component ───────────────────────────────────────────── */
|
||
export default function ProfilePage() {
|
||
const router = useRouter();
|
||
const { locale, setLocale, t } = useI18n();
|
||
const { settings, updateSettings } = useTranslationStore();
|
||
|
||
const [user, setUser] = useState<any>(null);
|
||
const [usage, setUsage] = useState<any>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||
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 searchParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||
const [activeTab, setActiveTab] = useState(searchParams?.get('tab') ?? 'account');
|
||
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
|
||
|
||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||
const authHeaders = { Authorization: `Bearer ${token}` };
|
||
|
||
const queryClient = useQueryClient();
|
||
|
||
const fetchData = useCallback(async () => {
|
||
if (!token) { router.push('/auth/login?redirect=/dashboard/profile'); return; }
|
||
try {
|
||
const [meRes, usageRes] = await Promise.all([
|
||
fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }),
|
||
fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }),
|
||
]);
|
||
if (meRes.ok) {
|
||
const j = await meRes.json();
|
||
const userData = j.data ?? j;
|
||
setUser(userData);
|
||
queryClient.setQueryData(['user', 'me'], userData);
|
||
}
|
||
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
|
||
} catch { /* ignore */ } finally { setLoading(false); }
|
||
}, [token, queryClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
useEffect(() => { fetchData(); }, [fetchData]);
|
||
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
|
||
|
||
const handleBillingPortal = async () => {
|
||
setLoadingPortal(true);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/v1/auth/billing-portal`, { headers: authHeaders });
|
||
const j = await res.json();
|
||
const url = j.data?.url ?? j.url;
|
||
if (url) window.location.href = url; // same tab so return_url brings back to app
|
||
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 });
|
||
if (res.ok) {
|
||
setStatusMsg({ type: 'ok', text: t('profile.subscription.cancelSuccess') });
|
||
setCancelConfirm(false);
|
||
fetchData();
|
||
} 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)));
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
window.location.reload();
|
||
} catch (error) {
|
||
console.error('Error clearing cache:', error);
|
||
setIsClearing(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<RefreshCw className="w-8 h-8 text-brand-accent animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const planId = user?.plan ?? user?.tier ?? 'free';
|
||
const planLabel = t(PLAN_LABELS[planId] ?? planId);
|
||
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
|
||
const planPrice = PLAN_PRICES[planId];
|
||
const isFreePlan = planId === 'free';
|
||
const isCanceling = user?.cancel_at_period_end === true;
|
||
|
||
const docsUsed = usage?.docs_used ?? user?.docs_translated_this_month ?? 0;
|
||
const docsLimit = usage?.docs_limit ?? user?.plan_limits?.docs_per_month ?? 5;
|
||
const pagesUsed = usage?.pages_used ?? user?.pages_translated_this_month ?? 0;
|
||
const pagesLimit = usage?.pages_limit ?? user?.plan_limits?.max_pages_per_doc ?? 50;
|
||
const extraCredits = usage?.extra_credits ?? user?.extra_credits ?? 0;
|
||
|
||
const docsPct = pct(docsUsed, docsLimit);
|
||
const pagesPct = pct(pagesUsed, pagesLimit);
|
||
|
||
return (
|
||
<div className="flex h-full flex-col overflow-y-auto">
|
||
<div className="flex flex-1 flex-col gap-8 p-6 lg:p-8 max-w-4xl mx-auto w-full">
|
||
|
||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||
<div className="mb-4">
|
||
<span className="accent-pill mb-4 block w-fit">{t('profile.header.title')}</span>
|
||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||
{formatTitleWithItalic(t('profile.header.title'))}
|
||
</h1>
|
||
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">{t('profile.header.subtitle')}</p>
|
||
</div>
|
||
|
||
{/* Status message */}
|
||
{statusMsg && (
|
||
<div className={cn(
|
||
'flex items-start gap-3 p-4 rounded-xl border text-sm',
|
||
statusMsg.type === 'ok'
|
||
? '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 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>
|
||
)}
|
||
|
||
{/* ── Editorial Pill Tabs ────────────────────────────────── */}
|
||
<div className="flex gap-2 p-2 bg-brand-muted dark:bg-[#141414] rounded-2xl w-fit border border-black/5 dark:border-white/10">
|
||
{[
|
||
{ key: 'account', label: t('profile.tabs.account'), icon: User },
|
||
{ key: 'subscription', label: t('profile.tabs.subscription'), icon: CreditCard },
|
||
{ key: 'preferences', label: t('profile.tabs.preferences'), icon: Settings },
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={cn(
|
||
'px-10 py-3 rounded-xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center gap-2',
|
||
activeTab === tab.key
|
||
? 'bg-brand-dark text-white dark:bg-white dark:text-brand-dark shadow-xl'
|
||
: 'text-brand-dark/30 dark:text-white/30 hover:text-brand-dark dark:hover:text-white'
|
||
)}
|
||
>
|
||
<tab.icon size={14} />
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Tab: Account ────────────────────────────────────── */}
|
||
{activeTab === 'account' && (
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex flex-col sm:flex-row items-center gap-8 lg:gap-10">
|
||
<div className="w-24 h-24 bg-brand-dark dark:bg-white/10 rounded-[32px] flex items-center justify-center text-white dark:text-white text-4xl font-black shadow-2xl shrink-0">
|
||
{getInitials(user?.name)}
|
||
</div>
|
||
<div className="flex-1 text-center sm:text-left">
|
||
<div className="flex items-center gap-4 mb-3 flex-wrap justify-center sm:justify-start">
|
||
<h2 className="text-2xl lg:text-3xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||
{user?.name || t('profile.account.user')}
|
||
</h2>
|
||
<span className="accent-pill !px-3 !py-1 text-[9px] flex items-center gap-1.5">
|
||
<PlanIcon size={12} />
|
||
{planLabel}
|
||
</span>
|
||
</div>
|
||
<p className="text-[11px] font-bold uppercase tracking-widest text-brand-dark/40 dark:text-white/40 mb-3 flex items-center gap-2 justify-center sm:justify-start">
|
||
<Globe size={12} className="text-brand-accent" />
|
||
{user?.email}
|
||
</p>
|
||
{user?.created_at && (
|
||
<p className="text-[9px] text-brand-dark/20 dark:text-white/20 font-black uppercase tracking-widest">
|
||
{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Tab: Subscription ───────────────────────────────── */}
|
||
{activeTab === 'subscription' && (
|
||
<div className="space-y-8">
|
||
{/* Plan card - dark editorial */}
|
||
<div className="editorial-card !bg-brand-dark p-10 lg:p-12 flex flex-col md:flex-row items-start md:items-center justify-between text-white border-none shadow-2xl relative overflow-hidden gap-6">
|
||
{/* Decorative element */}
|
||
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-accent/10 rounded-bl-full pointer-events-none" />
|
||
|
||
<div className="flex items-center gap-8 relative z-10">
|
||
<div className="w-16 h-16 bg-white/10 backdrop-blur-xl rounded-2xl flex items-center justify-center text-brand-accent border border-white/10 shadow-inner shrink-0">
|
||
<PlanIcon size={28} />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<h3 className="text-2xl font-serif font-medium text-white tracking-tight">
|
||
{t('profile.plan.label')} {planLabel}
|
||
</h3>
|
||
<span className={cn(
|
||
'px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border shadow-sm',
|
||
isCanceling
|
||
? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
|
||
: user?.subscription_status === 'active' || isFreePlan
|
||
? 'bg-brand-accent/20 text-brand-accent border-brand-accent/30'
|
||
: 'bg-red-500/20 text-red-300 border-red-500/30'
|
||
)}>
|
||
{isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : isFreePlan ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
|
||
</span>
|
||
</div>
|
||
<p className="text-white/60 text-[10px] font-black uppercase tracking-widest mt-1">
|
||
{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{!isFreePlan && (
|
||
<div className="relative z-10 flex flex-col sm:flex-row gap-3">
|
||
{/* Change plan → pricing page */}
|
||
<Link href="/pricing">
|
||
<button className="px-10 py-4 bg-white text-brand-dark rounded-xl font-black text-[10px] uppercase tracking-widest shadow-xl hover:scale-105 transition-all shrink-0">
|
||
{t('profile.subscription.changePlan')}
|
||
</button>
|
||
</Link>
|
||
{/* Billing portal → invoices / cancel / payment method */}
|
||
<button
|
||
onClick={handleBillingPortal}
|
||
disabled={loadingPortal}
|
||
className="px-10 py-4 bg-white/10 border border-white/20 text-white rounded-xl font-black text-[10px] uppercase tracking-widest hover:bg-white/20 transition-all shrink-0 disabled:opacity-50"
|
||
>
|
||
{loadingPortal ? <RefreshCw className="w-4 h-4 animate-spin inline" /> : t('profile.subscription.manageBilling')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isFreePlan && (
|
||
<Link href="/pricing">
|
||
<button className="relative z-10 px-10 py-4 bg-white text-brand-dark rounded-xl font-black text-[10px] uppercase tracking-widest shadow-xl hover:scale-105 transition-all shrink-0">
|
||
{t('profile.subscription.upgradePlan')}
|
||
</button>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
|
||
{/* Subscription ends info */}
|
||
{!isFreePlan && user?.subscription_ends_at && (
|
||
<div className="flex items-center gap-3 p-4 rounded-xl bg-brand-muted dark:bg-white/5 text-sm">
|
||
<Info className="w-4 h-4 text-brand-dark/40 dark:text-white/40 shrink-0" />
|
||
<span className="text-brand-dark/60 dark:text-white/60">
|
||
{isCanceling
|
||
? `${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>
|
||
)}
|
||
|
||
{/* Usage metrics */}
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||
<div className="flex justify-between items-center mb-12">
|
||
<div className="flex items-center gap-4 text-brand-accent">
|
||
<Activity size={20} />
|
||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||
{t('profile.usage.title')}
|
||
</span>
|
||
</div>
|
||
<span className="text-[9px] font-black text-brand-dark/20 dark:text-white/20 uppercase tracking-widest border-b border-black/5 dark:border-white/10 pb-1">
|
||
{t('profile.usage.resetOn')} {nextResetDate(locale)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-16">
|
||
{/* Documents bar */}
|
||
<div>
|
||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest mb-4">
|
||
<span className="flex items-center gap-3 text-brand-dark/40 dark:text-white/40">
|
||
<FileText size={16} className="text-brand-accent" /> {t('profile.usage.documents')}
|
||
</span>
|
||
<span className="text-brand-dark dark:text-white font-black">{docsUsed} / {fmtLimit(docsLimit)}</span>
|
||
</div>
|
||
<div className="h-2 bg-brand-muted dark:bg-white/10 rounded-full overflow-hidden p-0.5 border border-black/5 dark:border-white/10">
|
||
<div
|
||
className={cn(
|
||
'h-full rounded-full transition-all duration-700',
|
||
docsPct >= 90 ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]' :
|
||
docsPct >= 70 ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.5)]' :
|
||
'bg-brand-accent shadow-[0_0_10px_rgba(197,161,122,0.5)]'
|
||
)}
|
||
style={{ width: `${docsPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pages bar */}
|
||
<div>
|
||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest mb-4">
|
||
<span className="flex items-center gap-3 text-brand-dark/40 dark:text-white/40">
|
||
<Layers size={16} className="text-brand-accent" /> {t('profile.usage.pages')}
|
||
</span>
|
||
<span className="text-brand-dark dark:text-white font-black">{pagesUsed} / {fmtLimit(pagesLimit)}</span>
|
||
</div>
|
||
<div className="h-2 bg-brand-muted dark:bg-white/10 rounded-full overflow-hidden p-0.5 border border-black/5 dark:border-white/10">
|
||
<div
|
||
className={cn(
|
||
'h-full rounded-full transition-all duration-700',
|
||
pagesPct >= 90 ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]' :
|
||
pagesPct >= 70 ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.5)]' :
|
||
'bg-brand-accent shadow-[0_0_10px_rgba(197,161,122,0.5)]'
|
||
)}
|
||
style={{ width: `${pagesPct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Extra credits */}
|
||
{extraCredits > 0 && (
|
||
<div className="mt-12 flex items-center gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-sm">
|
||
<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>
|
||
)}
|
||
|
||
{/* Quota reached warning */}
|
||
{usage?.upgrade_required && (
|
||
<div className="mt-8 flex items-start gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-sm">
|
||
<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">{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>
|
||
<Link href="/pricing">
|
||
<button className="px-4 py-2 bg-red-600 text-white rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-red-500 transition-all shrink-0">
|
||
<ArrowRight size={14} />
|
||
</button>
|
||
</Link>
|
||
</div>
|
||
)}
|
||
|
||
{/* Upgrade CTA */}
|
||
{isFreePlan && (
|
||
<div className="mt-12 p-8 bg-brand-muted dark:bg-white/5 rounded-[32px] border border-black/5 dark:border-white/10 flex flex-col sm:flex-row items-center justify-between gap-4 group">
|
||
<div className="flex items-center gap-6 text-brand-dark dark:text-white">
|
||
<div className="w-12 h-12 bg-white dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shadow-sm group-hover:rotate-12 transition-transform">
|
||
<Zap size={24} />
|
||
</div>
|
||
<p className="text-[11px] font-black uppercase tracking-tight max-w-[200px] leading-relaxed">
|
||
{t('profile.usage.unlockMore')}
|
||
</p>
|
||
</div>
|
||
<Link href="/pricing">
|
||
<button className="px-8 py-3 bg-brand-dark dark:bg-brand-accent text-white dark:text-brand-dark rounded-xl text-[9px] font-black uppercase tracking-widest shadow-lg hover:bg-brand-accent hover:text-brand-dark transition-all">
|
||
{t('profile.usage.viewPlans')}
|
||
</button>
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Features grid */}
|
||
{user?.plan_limits?.features?.length > 0 && (
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||
<div className="flex items-center gap-4 text-brand-accent mb-12">
|
||
<CheckCircle2 size={20} />
|
||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||
{t('profile.usage.includedInPlan')}
|
||
</span>
|
||
</div>
|
||
<div className="grid md:grid-cols-2 gap-y-6 gap-x-12">
|
||
{user.plan_limits.features.map((f: string, i: number) => (
|
||
<div key={i} className="flex items-center gap-3">
|
||
<div className="w-5 h-5 rounded-full border border-brand-accent/30 flex items-center justify-center bg-brand-accent/5 shrink-0">
|
||
<CheckCircle2 size={12} className="text-brand-accent" />
|
||
</div>
|
||
<span className="text-[10px] font-bold uppercase tracking-tight text-brand-dark/60 dark:text-white/60">
|
||
{f.includes('.') ? t(f) : f}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Danger zone — show if user has a paid plan (even if sub ID not yet synced) */}
|
||
{!isFreePlan && !isCanceling && (
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial border-l-4 border-l-red-500">
|
||
<div className="flex items-center gap-4 text-red-500 mb-8">
|
||
<ShieldAlert size={20} />
|
||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||
{t('profile.danger.title')}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm text-brand-dark/40 dark:text-white/40 mb-6">{t('profile.danger.description')}</p>
|
||
{cancelConfirm && (
|
||
<div className="p-4 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 text-sm text-red-600 dark:text-red-400 mb-4">
|
||
⚠️ {t('profile.danger.confirm')}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handleCancel}
|
||
disabled={loadingCancel}
|
||
className="px-8 py-3 bg-red-600 text-white rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-red-500 transition-all disabled:opacity-50"
|
||
>
|
||
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin inline" />}
|
||
{cancelConfirm ? t('profile.danger.confirmCancel') : t('profile.danger.cancelSubscription')}
|
||
</button>
|
||
{cancelConfirm && (
|
||
<button
|
||
onClick={() => setCancelConfirm(false)}
|
||
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all"
|
||
>
|
||
{t('profile.danger.keep')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Tab: Preferences ────────────────────────────────── */}
|
||
{activeTab === 'preferences' && (
|
||
<div className="space-y-8">
|
||
{/* Interface language */}
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||
<div className="flex items-center gap-5 mb-12">
|
||
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent">
|
||
<Globe size={24} />
|
||
</div>
|
||
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||
{t('profile.prefs.interfaceLang')}
|
||
</h3>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||
{UI_LANGUAGES.map((lang) => (
|
||
<button
|
||
key={lang.value}
|
||
onClick={() => setLocale(lang.value)}
|
||
className={cn(
|
||
'px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest border transition-all',
|
||
locale === lang.value
|
||
? 'bg-brand-dark dark:bg-white border-brand-dark dark:border-white text-white dark:text-brand-dark shadow-xl'
|
||
: 'bg-white dark:bg-white/5 border-black/5 dark:border-white/10 text-brand-dark/30 dark:text-white/30 hover:border-brand-accent/30 hover:text-brand-dark dark:hover:text-white'
|
||
)}
|
||
>
|
||
<span className="mr-1.5">{lang.flag}</span> {lang.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="mt-12 text-[9px] text-brand-dark/20 dark:text-white/20 font-black uppercase tracking-widest italic border-t border-black/5 dark:border-white/10 pt-6">
|
||
{t('profile.prefs.interfaceLangDesc')}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Default target language */}
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||
<div className="flex items-center gap-6">
|
||
<div className="w-14 h-14 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||
<FileText size={28} />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||
{t('profile.prefs.defaultTargetLang')}
|
||
</h3>
|
||
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
|
||
{t('profile.prefs.defaultTargetLangDesc')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-4 items-center">
|
||
<div className="relative">
|
||
<select
|
||
value={defaultLanguage}
|
||
onChange={(e) => setDefaultLanguage(e.target.value)}
|
||
className="appearance-none px-8 py-4 pr-12 bg-brand-muted dark:bg-white/10 rounded-2xl text-[10px] font-black uppercase tracking-widest border border-black/5 dark:border-white/10 w-[220px] outline-none focus:ring-2 focus:ring-brand-accent/20 cursor-pointer"
|
||
>
|
||
{languages.map((lang) => (
|
||
<option key={lang.code} value={lang.code}>
|
||
{lang.flag} {lang.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown size={14} className="absolute right-4 top-1/2 -translate-y-1/2 text-brand-accent pointer-events-none" />
|
||
</div>
|
||
<button onClick={handleSavePrefs} className="premium-button px-10 py-4 text-[10px] uppercase tracking-widest !rounded-2xl">
|
||
{t('profile.prefs.save')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Theme */}
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex items-center justify-between gap-6">
|
||
<div className="flex items-center gap-6">
|
||
<div className="w-14 h-14 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||
<Palette size={28} />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||
{t('profile.prefs.theme')}
|
||
</h3>
|
||
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
|
||
{t('profile.prefs.themeDesc')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<ThemeToggle />
|
||
</div>
|
||
|
||
{/* Cache */}
|
||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex items-center justify-between gap-6">
|
||
<div className="flex items-center gap-6">
|
||
<div className="w-14 h-14 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||
<Trash2 size={28} />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-2xl font-serif font-medium text-brand-dark dark:text-white tracking-tight">
|
||
{t('profile.prefs.cache')}
|
||
</h3>
|
||
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
|
||
{t('profile.prefs.cacheDesc')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={handleClearCache}
|
||
disabled={isClearing}
|
||
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all disabled:opacity-50"
|
||
>
|
||
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin inline" />{t('profile.prefs.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5 inline" />{t('profile.prefs.clearCache')}</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|