feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
497
frontend/src/app/dashboard/profile/page.tsx
Normal file
497
frontend/src/app/dashboard/profile/page.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
'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 } 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,
|
||||
} 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 { cn } from '@/lib/utils';
|
||||
|
||||
/* ─────────────── 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' },
|
||||
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,
|
||||
};
|
||||
|
||||
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() {
|
||||
const now = new Date();
|
||||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return next.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
interface UsageBarProps {
|
||||
label: string;
|
||||
used: number;
|
||||
limit: number;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
function UsageBar({ label, used, limit, icon }: UsageBarProps) {
|
||||
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-400' :
|
||||
p >= 90 ? 'text-red-400' : p >= 70 ? '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-accent'
|
||||
)}
|
||||
style={{ width: `${p}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
{ value: 'en', label: 'English', flag: '🇬🇧' },
|
||||
];
|
||||
|
||||
/* ─────────────── Main component ─────────────── */
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { locale, setLocale } = useI18n();
|
||||
|
||||
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 token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
const authHeaders = { Authorization: `Bearer ${token}` };
|
||||
|
||||
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(); setUser(j.data ?? j); }
|
||||
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
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.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);
|
||||
}
|
||||
};
|
||||
|
||||
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: 'Abonnement annulé. Vous conservez l\'accès jusqu\'à la fin de la période en cours.' });
|
||||
setCancelConfirm(false);
|
||||
fetchData();
|
||||
} else {
|
||||
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'annulation. Contactez le support.' });
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: 'err', text: 'Erreur réseau.' });
|
||||
} finally {
|
||||
setLoadingCancel(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<RefreshCw className="w-8 h-8 text-accent animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const planId = user?.plan ?? user?.tier ?? 'free';
|
||||
const planLabel = 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';
|
||||
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;
|
||||
|
||||
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">
|
||||
|
||||
{/* ── Page title ── */}
|
||||
<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>
|
||||
</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 flex-shrink-0 mt-0.5" />
|
||||
: <XCircle className="w-4 h-4 flex-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">
|
||||
|
||||
{/* ── LEFT column ── */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Identity card */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<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
|
||||
)}>
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-foreground truncate">{user?.name || 'Utilisateur'}</h2>
|
||||
<Badge className={cn('text-xs font-medium', planColors.badge)}>
|
||||
<PlanIcon className="w-3 h-3 mr-1" />
|
||||
{planLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Subscription card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-accent" />
|
||||
Mon abonnement
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="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>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Forfait {planLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isFreePlan ? 'Gratuit' : `${planPrice} €/mois`}
|
||||
</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'
|
||||
)}>
|
||||
{isCanceling ? 'Annulation en cours' :
|
||||
user?.subscription_status === 'active' ? 'Actif' :
|
||||
user?.subscription_status ?? 'Inconnu'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isFreePlan && user?.subscription_ends_at && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-secondary/40 text-sm">
|
||||
<Info className="w-4 h-4 text-muted-foreground flex-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' })}`}
|
||||
</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>
|
||||
{!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>
|
||||
)}
|
||||
</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-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 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BadgeCheck className="w-4 h-4 text-accent" />
|
||||
Utilisation ce mois-ci
|
||||
<span className="ml-auto text-xs text-muted-foreground font-normal">
|
||||
Réinitialisation le {nextResetDate()}
|
||||
</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" />}
|
||||
/>
|
||||
|
||||
{extraCredits > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-sm">
|
||||
<Info className="w-4 h-4 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-amber-300">
|
||||
{extraCredits} crédit{extraCredits > 1 ? 's' : ''} supplémentaire{extraCredits > 1 ? 's' : ''} disponible{extraCredits > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usage?.upgrade_required && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-300 font-medium">Quota atteint</p>
|
||||
<p className="text-red-400 text-xs mt-0.5">Passez à un forfait supérieur pour continuer à traduire.</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFreePlan && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/10 border border-accent/20 text-sm">
|
||||
<Zap className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span className="text-accent/90 flex-1">Débloquez plus de traductions avec un forfait payant.</span>
|
||||
<Button asChild size="sm" variant="outline" className="border-accent/30 text-accent hover:bg-accent/10 flex-shrink-0">
|
||||
<Link href="/pricing">Voir les forfaits <ArrowRight className="w-3.5 h-3.5 ml-1" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features included */}
|
||||
{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-400" />
|
||||
Inclus dans votre forfait
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 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-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Preferences */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-accent" />
|
||||
Préférences
|
||||
</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-accent/10 border-accent/40 text-accent'
|
||||
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
|
||||
)}
|
||||
>
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user