feat: rewrite all dashboard views with editorial design
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m29s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m29s
Translate page: editorial dropzone (rounded-[40px]), config panel, progress stepper, download button, Momento promo banner. Profile page: pill tabs, dark subscription card, usage bars, language grid. Context page: preset grid (rounded-[32px]), instruction/glossary textareas. API keys page: editorial-card with production key display. Glossaries page: editorial-card grid with hover lift. Pricing page: 5-column grid, colored headers, plan badges. All pages use editorial design system: accent-pill, editorial-card, premium-button, brand colors, dark mode support. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
|
||||
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
|
||||
BadgeCheck, ShieldAlert, Info, Globe, Settings, Palette, Trash2, Loader2,
|
||||
Activity, ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -29,13 +30,6 @@ import { cn } from '@/lib/utils';
|
||||
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' },
|
||||
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: 'profile.plan.free', starter: 'profile.plan.starter', pro: 'profile.plan.pro', business: 'profile.plan.business', enterprise: 'profile.plan.enterprise',
|
||||
};
|
||||
@@ -56,30 +50,6 @@ function nextResetDate(locale: Locale) {
|
||||
return formatDate(next, locale, { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">{icon} {label}</div>
|
||||
<span className={cn(
|
||||
'font-mono text-xs font-medium',
|
||||
isUnlimited ? 'text-emerald-600 dark:text-emerald-400' :
|
||||
p >= 90 ? 'text-red-600 dark:text-red-400' : p >= 70 ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'
|
||||
)}>
|
||||
{isUnlimited ? '∞' : `${used} / ${limit}`}
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
@@ -180,14 +150,13 @@ export default function ProfilePage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<RefreshCw className="w-8 h-8 text-primary animate-spin" />
|
||||
<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 planColors = PLAN_COLORS[planId] ?? PLAN_COLORS.free;
|
||||
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
|
||||
const planPrice = PLAN_PRICES[planId];
|
||||
const isFreePlan = planId === 'free';
|
||||
@@ -199,14 +168,23 @@ export default function ProfilePage() {
|
||||
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);
|
||||
|
||||
// Tab state for editorial pill toggle
|
||||
const [activeTab, setActiveTab] = useState('account');
|
||||
|
||||
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 max-w-3xl">
|
||||
<div className="flex flex-1 flex-col gap-8 p-6 lg:p-8 max-w-4xl mx-auto w-full">
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<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>
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
<div className="mb-4">
|
||||
<span className="accent-pill mb-4 block w-fit">{t('profile.header.title')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('profile.header.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('profile.header.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
@@ -223,273 +201,401 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* ── 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 ────────────────────────────────────── */}
|
||||
<TabsContent value="account" className="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className={cn(
|
||||
'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 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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 me-1" />{planLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<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 text-xs text-muted-foreground">
|
||||
<Calendar className="w-3 h-3 shrink-0" />
|
||||
<span>{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</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-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Tab: Subscription ───────────────────────────────── */}
|
||||
<TabsContent value="subscription" className="space-y-6 pt-4">
|
||||
|
||||
{/* Plan card */}
|
||||
<Card>
|
||||
<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-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 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 && (
|
||||
<Badge variant="secondary" className={cn(
|
||||
'text-xs',
|
||||
isCanceling ? 'text-warning border-warning/30 bg-warning/10' :
|
||||
user?.subscription_status === 'active' ? 'text-success border-success/30 bg-success/10' :
|
||||
'text-destructive border-destructive/30 bg-destructive/10'
|
||||
<div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-white">
|
||||
{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') : 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 shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
{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)}`}
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BadgeCheck className="w-4 h-4 text-primary" />
|
||||
{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={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 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 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>
|
||||
<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 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 */}
|
||||
{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" />
|
||||
{t('profile.usage.includedInPlan')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f.includes('.') ? t(f) : f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Danger zone - only show if user has a real Stripe subscription */}
|
||||
{!isFreePlan && !isCanceling && user?.stripe_subscription_id && (
|
||||
<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 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>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFreePlan && (
|
||||
<button
|
||||
onClick={handleBillingPortal}
|
||||
disabled={loadingPortal}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{loadingPortal ? <RefreshCw className="w-4 h-4 animate-spin" /> : t('profile.subscription.changePlan')}
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Tab: Preferences ────────────────────────────────── */}
|
||||
<TabsContent value="preferences" className="space-y-6 pt-4">
|
||||
{/* 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>
|
||||
|
||||
{/* Interface language */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-primary" /> {t('profile.prefs.interfaceLang')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{UI_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => setLocale(lang.value)}
|
||||
<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(
|
||||
'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'
|
||||
'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)]'
|
||||
)}
|
||||
>
|
||||
<span>{lang.flag}</span><span>{lang.label}</span>
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground mt-3">{t('profile.prefs.interfaceLangDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger zone */}
|
||||
{!isFreePlan && !isCanceling && user?.stripe_subscription_id && (
|
||||
<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-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{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 */}
|
||||
<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>
|
||||
))}
|
||||
</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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{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} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger className="px-8 py-4 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-[200px]">
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 */}
|
||||
<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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{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 */}
|
||||
<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 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-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user