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:
@@ -68,7 +68,7 @@ export default function ContextGlossaryPage() {
|
||||
<Brain className="h-3 w-3 mr-1" />
|
||||
Context & Glossary
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">
|
||||
Context & Glossary
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
@@ -130,7 +130,7 @@ export default function ContextGlossaryPage() {
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Context & Glossary Settings
|
||||
</h3>
|
||||
<p className="text-text-secondary leading-relaxed">
|
||||
@@ -152,7 +152,7 @@ export default function ContextGlossaryPage() {
|
||||
<Brain className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">System Prompt</CardTitle>
|
||||
<CardTitle className="text-foreground">System Prompt</CardTitle>
|
||||
<CardDescription>
|
||||
Instructions for LLM to follow during translation
|
||||
</CardDescription>
|
||||
@@ -167,7 +167,7 @@ export default function ContextGlossaryPage() {
|
||||
setLocalSettings({ ...localSettings, systemPrompt: e.target.value })
|
||||
}
|
||||
placeholder="Example: You are translating technical HVAC documents. Use precise engineering terminology. Maintain consistency with industry standards..."
|
||||
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary min-h-[200px] resize-y focus:border-primary focus:ring-primary/20"
|
||||
className="bg-surface border-border-subtle text-foreground placeholder:text-text-tertiary min-h-[200px] resize-y focus:border-primary focus:ring-primary/20"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<p className="text-sm text-primary flex items-start gap-2">
|
||||
@@ -188,7 +188,7 @@ export default function ContextGlossaryPage() {
|
||||
<Zap className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Quick Presets</CardTitle>
|
||||
<CardTitle className="text-foreground">Quick Presets</CardTitle>
|
||||
<CardDescription>
|
||||
Load pre-configured prompts & glossaries for common domains
|
||||
</CardDescription>
|
||||
@@ -263,7 +263,7 @@ export default function ContextGlossaryPage() {
|
||||
<BookOpen className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Technical Glossary</CardTitle>
|
||||
<CardTitle className="text-foreground">Technical Glossary</CardTitle>
|
||||
<CardDescription>
|
||||
Define specific term translations. Format: source=target (one per line)
|
||||
</CardDescription>
|
||||
@@ -278,7 +278,7 @@ export default function ContextGlossaryPage() {
|
||||
setLocalSettings({ ...localSettings, glossary: e.target.value })
|
||||
}
|
||||
placeholder="pression statique=static pressure récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct diffuseur=diffuser"
|
||||
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary min-h-[280px] resize-y font-mono text-sm focus:border-success focus:ring-success/20"
|
||||
className="bg-surface border-border-subtle text-foreground placeholder:text-text-tertiary min-h-[280px] resize-y font-mono text-sm focus:border-success focus:ring-success/20"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<p className="text-sm text-success flex items-start gap-2">
|
||||
@@ -299,7 +299,7 @@ export default function ContextGlossaryPage() {
|
||||
<AlertCircle className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Usage Examples</CardTitle>
|
||||
<CardTitle className="text-foreground">Usage Examples</CardTitle>
|
||||
<CardDescription>
|
||||
See how context and glossary improve translations
|
||||
</CardDescription>
|
||||
@@ -308,13 +308,13 @@ export default function ContextGlossaryPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-surface/50 border border-border-subtle">
|
||||
<h4 className="font-medium text-white mb-2">Before (Generic Translation)</h4>
|
||||
<h4 className="font-medium text-foreground mb-2">Before (Generic Translation)</h4>
|
||||
<p className="text-sm text-text-tertiary italic">
|
||||
"The pressure in the duct should be maintained."
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<h4 className="font-medium text-white mb-2">After (With Context & Glossary)</h4>
|
||||
<h4 className="font-medium text-foreground mb-2">After (With Context & Glossary)</h4>
|
||||
<p className="text-sm text-success italic">
|
||||
"La pression statique dans la gaine doit être maintenue."
|
||||
</p>
|
||||
@@ -333,7 +333,7 @@ export default function ContextGlossaryPage() {
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-foreground group"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from "lucide-react";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
|
||||
const FALLBACK_PROVIDERS = [
|
||||
{ id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import {
|
||||
Crown, Zap, Sparkles, Building2, Rocket, BadgeCheck,
|
||||
ArrowRight, AlertTriangle, CheckCircle2, XCircle,
|
||||
@@ -99,24 +100,24 @@ function UsageBar({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"font-mono text-xs",
|
||||
isUnlimited ? "text-emerald-400" :
|
||||
p >= 90 ? "text-red-400" : p >= 70 ? "text-amber-400" : "text-gray-400"
|
||||
isUnlimited ? "text-emerald-500" :
|
||||
p >= 90 ? "text-destructive" : p >= 70 ? "text-warning" : "text-muted-foreground"
|
||||
)}>
|
||||
{isUnlimited ? "∞" : `${used} / ${limit}`}
|
||||
</span>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 bg-gray-700/50 rounded-full overflow-hidden">
|
||||
<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-violet-500"
|
||||
p >= 90 ? "bg-destructive" : p >= 70 ? "bg-warning" : "bg-accent"
|
||||
)}
|
||||
style={{ width: `${p}%` }}
|
||||
/>
|
||||
@@ -142,6 +143,7 @@ export default function SubscriptionPage() {
|
||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isProcessingCheckout, setIsProcessingCheckout] = useState(false);
|
||||
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||
const authHeaders = { Authorization: `Bearer ${token}` };
|
||||
@@ -150,9 +152,9 @@ export default function SubscriptionPage() {
|
||||
if (!token) { router.push("/auth/login?redirect=/settings/subscription"); return; }
|
||||
try {
|
||||
const [meRes, usageRes, plansRes] = await Promise.all([
|
||||
fetch("/api/v1/auth/me", { headers: authHeaders }),
|
||||
fetch("/api/v1/auth/usage", { headers: authHeaders }),
|
||||
fetch("/api/v1/auth/plans"),
|
||||
fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }),
|
||||
fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }),
|
||||
fetch(`${API_BASE}/api/v1/auth/plans`),
|
||||
]);
|
||||
if (meRes.ok) {
|
||||
const j = await meRes.json();
|
||||
@@ -174,12 +176,13 @@ export default function SubscriptionPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleBillingPortal = async () => {
|
||||
setLoadingPortal(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/billing-portal", { headers: authHeaders });
|
||||
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");
|
||||
@@ -194,7 +197,7 @@ export default function SubscriptionPage() {
|
||||
const handleCancel = async () => {
|
||||
if (!cancelConfirm) { setCancelConfirm(true); return; }
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/cancel-subscription", {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
});
|
||||
@@ -210,21 +213,84 @@ export default function SubscriptionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = (planId: string) => {
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
if (planId === "enterprise") {
|
||||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||||
return;
|
||||
}
|
||||
// In a real app, this would initiate a Stripe Checkout session
|
||||
// For now, redirect to billing portal or show info
|
||||
|
||||
setStatusMsg({
|
||||
type: "ok",
|
||||
text: `Redirection vers Stripe pour activer le forfait ${PLAN_LABELS[planId] ?? planId}…`,
|
||||
text: `Création de votre session de paiement pour le forfait ${PLAN_LABELS[planId] ?? planId}…`,
|
||||
});
|
||||
setTimeout(() => handleBillingPortal(), 1000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/create-checkout`, {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan: planId, billing_period: isYearly ? "yearly" : "monthly" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data.message ?? data.error ?? "Erreur lors de la création de la session de paiement.";
|
||||
setStatusMsg({ type: "err", text: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = data.data?.url ?? data.url;
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
await handleBillingPortal();
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Erreur réseau lors de la création de la session de paiement." });
|
||||
} finally {
|
||||
setIsProcessingCheckout(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
// Handle targetPlan from URL query param automatically (MUST be after handleSubscribe declaration)
|
||||
useEffect(() => {
|
||||
if (targetPlan && plans.length > 0 && !loading && !isProcessingCheckout) {
|
||||
const p = plans.find(plan => plan.id === targetPlan);
|
||||
if (p && user?.plan !== targetPlan) {
|
||||
setIsProcessingCheckout(true);
|
||||
// Clean URL to prevent infinite re-triggering
|
||||
router.replace("/settings/subscription", { scroll: false });
|
||||
handleSubscribe(targetPlan);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetPlan, plans, loading, user]);
|
||||
|
||||
const handleCreditsCheckout = async (packageIndex: number) => {
|
||||
setStatusMsg({
|
||||
type: "ok",
|
||||
text: "Préparation de l'achat de crédits...",
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/create-credits-checkout`, {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ package_index: packageIndex }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const url = data.data?.url ?? data.url;
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
setTimeout(() => handleBillingPortal(), 1000);
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Erreur lors de la création de la session de paiement." });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || isProcessingCheckout) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<RefreshCw className="w-8 h-8 text-violet-400 animate-spin" />
|
||||
@@ -251,8 +317,8 @@ export default function SubscriptionPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-10 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Mon abonnement</h1>
|
||||
<p className="text-gray-400 mt-1">Gérez votre forfait, votre usage et votre facturation.</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">Mon abonnement</h1>
|
||||
<p className="text-muted-foreground mt-1">Gérez votre forfait, votre usage et votre facturation.</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
@@ -260,20 +326,20 @@ export default function SubscriptionPage() {
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-xl border",
|
||||
statusMsg.type === "ok"
|
||||
? "bg-emerald-900/20 border-emerald-600/30 text-emerald-300"
|
||||
: "bg-red-900/20 border-red-600/30 text-red-300"
|
||||
? "bg-success/10 border-success/30 text-success"
|
||||
: "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
)}>
|
||||
{statusMsg.type === "ok"
|
||||
? <CheckCircle2 className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
: <XCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />}
|
||||
<span className="text-sm">{statusMsg.text}</span>
|
||||
<button className="ml-auto text-gray-500 hover:text-white" onClick={() => setStatusMsg(null)}>✕</button>
|
||||
<button className="ml-auto text-muted-foreground hover:text-foreground" onClick={() => setStatusMsg(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Current plan card ── */}
|
||||
<div className={cn("rounded-2xl p-1 bg-gradient-to-br", gradient)}>
|
||||
<div className="bg-gray-900/90 rounded-xl p-6">
|
||||
<div className="bg-card/95 rounded-xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn("p-3 rounded-xl bg-gradient-to-br", gradient)}>
|
||||
@@ -281,13 +347,13 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-white">Forfait {currentPlanLabel}</h2>
|
||||
<h2 className="text-xl font-bold text-foreground">Forfait {currentPlanLabel}</h2>
|
||||
{user?.subscription_status && (
|
||||
<Badge className={cn(
|
||||
"text-xs",
|
||||
user.subscription_status === "active" ? "bg-emerald-600/20 text-emerald-300 border-emerald-600/30" :
|
||||
user.subscription_status === "trialing" ? "bg-blue-600/20 text-blue-300 border-blue-600/30" :
|
||||
"bg-amber-600/20 text-amber-300 border-amber-600/30"
|
||||
user.subscription_status === "active" ? "bg-success/10 text-success border-success/30" :
|
||||
user.subscription_status === "trialing" ? "bg-primary/10 text-primary border-primary/30" :
|
||||
"bg-warning/10 text-warning border-warning/30"
|
||||
)}>
|
||||
{user.subscription_status === "active" ? "Actif" :
|
||||
user.subscription_status === "trialing" ? "Essai" :
|
||||
@@ -297,7 +363,7 @@ export default function SubscriptionPage() {
|
||||
)}
|
||||
</div>
|
||||
{user?.subscription_ends_at && (
|
||||
<p className="text-gray-400 text-sm mt-0.5">
|
||||
<p className="text-muted-foreground text-sm mt-0.5">
|
||||
<Calendar className="w-3.5 h-3.5 inline mr-1" />
|
||||
{user.cancel_at_period_end ? "Expire le " : "Renouvellement le "}
|
||||
{new Date(user.subscription_ends_at).toLocaleDateString("fr-FR", { day: "2-digit", month: "long", year: "numeric" })}
|
||||
@@ -309,7 +375,6 @@ export default function SubscriptionPage() {
|
||||
{currentPlanId !== "free" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/30"
|
||||
onClick={handleBillingPortal}
|
||||
disabled={loadingPortal}
|
||||
>
|
||||
@@ -320,7 +385,7 @@ export default function SubscriptionPage() {
|
||||
)}
|
||||
{upgradePlans.length > 0 && (
|
||||
<Button
|
||||
className="bg-violet-600 hover:bg-violet-500"
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground"
|
||||
onClick={() => router.push("/pricing")}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-1" /> Upgrader
|
||||
@@ -333,12 +398,12 @@ export default function SubscriptionPage() {
|
||||
|
||||
{/* ── Usage this month ── */}
|
||||
{usage && (
|
||||
<Card className="bg-gray-900/60 border-gray-700/40">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-violet-400" />
|
||||
<BarChart3 className="w-5 h-5 text-accent" />
|
||||
Utilisation ce mois
|
||||
<span className="ml-auto text-xs text-gray-500 font-normal">Remise à zéro chaque 1er du mois</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground font-normal">Remise à zéro chaque 1er du mois</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
@@ -346,37 +411,37 @@ export default function SubscriptionPage() {
|
||||
label="Documents traduits"
|
||||
used={usage.docs_used}
|
||||
limit={usage.docs_limit}
|
||||
icon={<FileText className="w-4 h-4 text-gray-400" />}
|
||||
icon={<FileText className="w-4 h-4 text-muted-foreground" />}
|
||||
/>
|
||||
<UsageBar
|
||||
label="Pages traduites"
|
||||
used={usage.pages_used}
|
||||
limit={usage.pages_limit}
|
||||
icon={<Layers className="w-4 h-4 text-gray-400" />}
|
||||
icon={<Layers className="w-4 h-4 text-muted-foreground" />}
|
||||
/>
|
||||
{usage.api_calls_limit !== 0 && (
|
||||
<UsageBar
|
||||
label="Appels API"
|
||||
used={usage.api_calls_used}
|
||||
limit={usage.api_calls_limit}
|
||||
icon={<Gauge className="w-4 h-4 text-gray-400" />}
|
||||
icon={<Gauge className="w-4 h-4 text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_credits > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl 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">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-warning/10 border border-warning/20 text-sm">
|
||||
<Info className="w-4 h-4 text-warning flex-shrink-0" />
|
||||
<span className="text-warning">
|
||||
{usage.extra_credits} crédit{usage.extra_credits > 1 ? "s" : ""} supplémentaire{usage.extra_credits > 1 ? "s" : ""} disponible{usage.extra_credits > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{usage.upgrade_required && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||
<span className="text-red-300">
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-destructive/10 border border-destructive/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
<span className="text-destructive">
|
||||
Quota atteint. Achetez des crédits ou upgradez votre forfait pour continuer.
|
||||
</span>
|
||||
<Button size="sm" className="ml-auto bg-red-600 hover:bg-red-500 flex-shrink-0" onClick={() => router.push("/pricing")}>
|
||||
<Button size="sm" className="ml-auto bg-destructive hover:bg-destructive/90 text-destructive-foreground flex-shrink-0" onClick={() => router.push("/pricing")}>
|
||||
Upgrader
|
||||
</Button>
|
||||
</div>
|
||||
@@ -387,10 +452,10 @@ export default function SubscriptionPage() {
|
||||
|
||||
{/* ── Plan features recap ── */}
|
||||
{currentPlanData && (
|
||||
<Card className="bg-gray-900/60 border-gray-700/40">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BadgeCheck className="w-5 h-5 text-emerald-400" />
|
||||
<BadgeCheck className="w-5 h-5 text-success" />
|
||||
Inclus dans votre forfait
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -398,8 +463,8 @@ export default function SubscriptionPage() {
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{currentPlanData.features.map((f, i) => (
|
||||
<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-gray-300">{f}</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -410,23 +475,23 @@ export default function SubscriptionPage() {
|
||||
{/* ── Upgrade options ── */}
|
||||
{upgradePlans.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-violet-400" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
Passer à un forfait supérieur
|
||||
</h3>
|
||||
|
||||
{/* Billing toggle */}
|
||||
<div className="inline-flex items-center gap-2 bg-gray-800/60 border border-gray-700/50 rounded-full p-1 mb-4">
|
||||
<div className="inline-flex items-center gap-2 bg-muted/60 border border-border/50 rounded-full p-1 mb-4">
|
||||
<button
|
||||
onClick={() => setIsYearly(false)}
|
||||
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all", !isYearly ? "bg-white text-gray-900" : "text-gray-400")}
|
||||
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all", !isYearly ? "bg-foreground text-background" : "text-muted-foreground")}
|
||||
>Mensuel</button>
|
||||
<button
|
||||
onClick={() => setIsYearly(true)}
|
||||
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5", isYearly ? "bg-white text-gray-900" : "text-gray-400")}
|
||||
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5", isYearly ? "bg-foreground text-background" : "text-muted-foreground")}
|
||||
>
|
||||
Annuel
|
||||
<span className="bg-emerald-500 text-white text-xs px-1.5 py-0.5 rounded-full">−20 %</span>
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">−20 %</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -444,13 +509,13 @@ export default function SubscriptionPage() {
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"relative rounded-2xl border bg-gray-900/60 overflow-hidden",
|
||||
plan.popular ? "border-violet-500/50" : "border-gray-700/40"
|
||||
"relative rounded-2xl border bg-card overflow-hidden",
|
||||
plan.popular ? "border-accent/50" : "border-border/40"
|
||||
)}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Badge className="bg-violet-600 text-white text-xs">{plan.badge}</Badge>
|
||||
<Badge className="bg-accent text-accent-foreground text-xs">{plan.badge}</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("p-4 bg-gradient-to-br", grad)}>
|
||||
@@ -471,13 +536,13 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{plan.features.slice(0, 4).map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs text-gray-300">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<div key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-success flex-shrink-0 mt-0.5" />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
{plan.features.length > 4 && (
|
||||
<p className="text-xs text-gray-500">+{plan.features.length - 4} autres avantages…</p>
|
||||
<p className="text-xs text-muted-foreground">+{plan.features.length - 4} autres avantages…</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
@@ -497,13 +562,13 @@ export default function SubscriptionPage() {
|
||||
)}
|
||||
|
||||
{/* ── Buy credits ── */}
|
||||
<Card className="bg-gray-900/60 border-gray-700/40">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-amber-400" />
|
||||
<CreditCard className="w-5 h-5 text-warning" />
|
||||
Crédits supplémentaires
|
||||
</CardTitle>
|
||||
<p className="text-gray-400 text-sm">1 crédit = 1 page traduite. Utilisables sans expiration.</p>
|
||||
<p className="text-muted-foreground text-sm">1 crédit = 1 page traduite. Utilisables sans expiration.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
@@ -518,22 +583,22 @@ export default function SubscriptionPage() {
|
||||
className={cn(
|
||||
"relative p-4 rounded-xl border text-center",
|
||||
pkg.popular
|
||||
? "border-amber-500/50 bg-amber-500/10"
|
||||
: "border-gray-700/40 bg-gray-800/30"
|
||||
? "border-warning/50 bg-warning/10"
|
||||
: "border-border/40 bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{pkg.popular && (
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-amber-600 text-white text-xs rounded-full font-bold whitespace-nowrap">
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-warning text-warning-foreground text-xs rounded-full font-bold whitespace-nowrap">
|
||||
Meilleure valeur
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-bold text-white">{pkg.credits}</div>
|
||||
<div className="text-gray-400 text-xs mb-2">crédits</div>
|
||||
<div className="text-lg font-bold text-white">{pkg.price} €</div>
|
||||
<div className="text-gray-500 text-xs mb-3">{((pkg.price / pkg.credits) * 100).toFixed(0)} cts/crédit</div>
|
||||
<div className="text-xl font-bold text-foreground">{pkg.credits}</div>
|
||||
<div className="text-muted-foreground text-xs mb-2">crédits</div>
|
||||
<div className="text-lg font-bold text-foreground">{pkg.price} €</div>
|
||||
<div className="text-muted-foreground text-xs mb-3">{((pkg.price / pkg.credits) * 100).toFixed(0)} cts/crédit</div>
|
||||
<button
|
||||
onClick={handleBillingPortal}
|
||||
className="w-full py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-xs transition-all"
|
||||
onClick={() => handleCreditsCheckout(i)}
|
||||
className="w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all"
|
||||
>
|
||||
Acheter
|
||||
</button>
|
||||
@@ -545,9 +610,9 @@ export default function SubscriptionPage() {
|
||||
|
||||
{/* ── Downgrade / Cancel ── */}
|
||||
{currentPlanId !== "free" && (
|
||||
<Card className="bg-gray-900/60 border-gray-700/40">
|
||||
<Card className="border-destructive/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg text-red-400 flex items-center gap-2">
|
||||
<CardTitle className="text-lg text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Zone de danger
|
||||
</CardTitle>
|
||||
@@ -555,13 +620,13 @@ export default function SubscriptionPage() {
|
||||
<CardContent className="space-y-4">
|
||||
{downgradePlans.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-2">Rétrograder vers un forfait inférieur :</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">Rétrograder vers un forfait inférieur :</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{downgradePlans.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleSubscribe(p.id)}
|
||||
className="px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm border border-gray-700/50 transition-all"
|
||||
className="px-4 py-2 rounded-lg bg-muted hover:bg-secondary text-muted-foreground text-sm border border-border/50 transition-all"
|
||||
>
|
||||
Passer à {p.name}
|
||||
</button>
|
||||
@@ -570,40 +635,39 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-700/30 pt-4">
|
||||
<div className="border-t border-border/30 pt-4">
|
||||
{!cancelConfirm ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Annuler mon abonnement</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Vous conservez l'accès jusqu'à la fin de la période payée.</p>
|
||||
<p className="text-sm font-medium text-foreground">Annuler mon abonnement</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Vous conservez l'accès jusqu'à la fin de la période payée.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-700/50 text-red-400 hover:bg-red-900/20"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setCancelConfirm(true)}
|
||||
>
|
||||
Annuler l'abonnement
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-red-900/20 border border-red-600/30 space-y-3">
|
||||
<p className="text-sm text-red-300 font-medium">
|
||||
<div className="p-4 rounded-xl bg-destructive/10 border border-destructive/30 space-y-3">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
⚠️ Confirmer l'annulation ?
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Votre abonnement sera annulé et vous reviendrez au forfait Gratuit à la fin de la période en cours.
|
||||
Vos documents traduits resteront accessibles pendant 30 jours.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="bg-red-600 hover:bg-red-500 text-white"
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Oui, annuler mon abonnement
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300"
|
||||
onClick={() => setCancelConfirm(false)}
|
||||
>
|
||||
Non, conserver
|
||||
@@ -620,7 +684,7 @@ export default function SubscriptionPage() {
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => router.push("/pricing")}
|
||||
className="text-violet-400 hover:text-violet-300 text-sm flex items-center gap-1 mx-auto"
|
||||
className="text-accent hover:text-accent/80 text-sm flex items-center gap-1 mx-auto"
|
||||
>
|
||||
Voir tous les forfaits en détail <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutDashboard, Users, Settings, FileText, Key, type LucideIcon } from 'lucide-react';
|
||||
import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AdminNavItem {
|
||||
label: string;
|
||||
@@ -9,7 +9,8 @@ export interface AdminNavItem {
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ label: 'Providers', href: '/admin/settings', icon: Key },
|
||||
{ label: 'Pricing & Stripe', href: '/admin/pricing', icon: CreditCard },
|
||||
{ label: 'Providers', href: '/admin/settings', icon: Settings },
|
||||
{ label: 'System', href: '/admin/system', icon: Settings },
|
||||
{ label: 'Logs', href: '/admin/logs', icon: FileText },
|
||||
];
|
||||
|
||||
@@ -17,6 +17,18 @@ export default function AdminLayout({
|
||||
const { settings, setAdminToken } = useTranslationStore();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
/** Sans ça, au premier rendu après un chargement complet le token n’est pas encore lu depuis localStorage → fausse absence de token → redirection /admin/login (bug sur F5 ou URL directe). */
|
||||
const [persistHydrated, setPersistHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = useTranslationStore.persist.onFinishHydration(() => {
|
||||
setPersistHydrated(true);
|
||||
});
|
||||
if (useTranslationStore.persist.hasHydrated()) {
|
||||
setPersistHydrated(true);
|
||||
}
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const verifyToken = useCallback(async (token: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -39,6 +51,10 @@ export default function AdminLayout({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!persistHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminToken = settings.adminToken;
|
||||
if (!adminToken) {
|
||||
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
@@ -54,7 +70,7 @@ export default function AdminLayout({
|
||||
setIsValid(true);
|
||||
setIsChecking(false);
|
||||
});
|
||||
}, [settings.adminToken, pathname, router, verifyToken, setAdminToken]);
|
||||
}, [settings.adminToken, pathname, router, verifyToken, setAdminToken, persistHydrated]);
|
||||
|
||||
if (isChecking && pathname !== "/admin/login") {
|
||||
return (
|
||||
|
||||
552
frontend/src/app/admin/pricing/page.tsx
Normal file
552
frontend/src/app/admin/pricing/page.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
CreditCard, Save, Loader2, CheckCircle, XCircle,
|
||||
Zap, RefreshCw, ExternalLink, AlertTriangle, KeyRound,
|
||||
Crown, Building2, Sparkles
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import {
|
||||
ANNUAL_DISCOUNT_PERCENT,
|
||||
YEARLY_DISCOUNT_FACTOR,
|
||||
computeYearlyFromMonthly,
|
||||
} from "@/lib/pricing";
|
||||
|
||||
/* ─── Types ─── */
|
||||
interface PlanConfig {
|
||||
name: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
stripe_price_id_monthly: string;
|
||||
stripe_price_id_yearly: string;
|
||||
}
|
||||
interface PricingData {
|
||||
starter: PlanConfig;
|
||||
pro: PlanConfig;
|
||||
business: PlanConfig;
|
||||
}
|
||||
interface StripeStatus {
|
||||
configured: boolean;
|
||||
has_secret_key: boolean;
|
||||
has_publishable_key: boolean;
|
||||
has_webhook_secret: boolean;
|
||||
publishable_key: string;
|
||||
}
|
||||
|
||||
const PLAN_ICONS: Record<string, React.ElementType> = {
|
||||
starter: Zap,
|
||||
pro: Crown,
|
||||
business: Building2,
|
||||
};
|
||||
|
||||
/** Le backend refuse les placeholders (xxx) — envoyer une chaîne vide pour ne pas bloquer la sauvegarde des prix. */
|
||||
function sanitizeStripePriceIdForSave(raw: string): string {
|
||||
const s = raw.trim();
|
||||
if (!s) return "";
|
||||
const lower = s.toLowerCase();
|
||||
if (lower.includes("xxx") || lower.includes("placeholder")) return "";
|
||||
return s;
|
||||
}
|
||||
|
||||
const EMPTY_PLAN: PlanConfig = {
|
||||
name: "",
|
||||
price_monthly: 0,
|
||||
price_yearly: 0,
|
||||
stripe_price_id_monthly: "",
|
||||
stripe_price_id_yearly: "",
|
||||
};
|
||||
|
||||
/** Aligné sur models/subscription.py — éviter 0 € initial : le backend refuse < 0,01 € et la sauvegarde échouait pour Pro/Business. */
|
||||
function defaultPricingData(): PricingData {
|
||||
const s = 9;
|
||||
const p = 19;
|
||||
const b = 49;
|
||||
return {
|
||||
starter: {
|
||||
...EMPTY_PLAN,
|
||||
name: "Starter",
|
||||
price_monthly: s,
|
||||
price_yearly: computeYearlyFromMonthly(s),
|
||||
},
|
||||
pro: {
|
||||
...EMPTY_PLAN,
|
||||
name: "Pro",
|
||||
price_monthly: p,
|
||||
price_yearly: computeYearlyFromMonthly(p),
|
||||
},
|
||||
business: {
|
||||
...EMPTY_PLAN,
|
||||
name: "Business",
|
||||
price_monthly: b,
|
||||
price_yearly: computeYearlyFromMonthly(b),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminPricingPage() {
|
||||
const [pricing, setPricing] = useState<PricingData>(defaultPricingData);
|
||||
const [stripeStatus, setStripeStatus] = useState<StripeStatus | null>(null);
|
||||
const [stripeKeys, setStripeKeys] = useState({
|
||||
secret: "",
|
||||
publishable: "",
|
||||
webhook: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<any>(null);
|
||||
const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||
|
||||
const showToast = (type: "ok" | "err", text: string) => {
|
||||
setToast({ type, text });
|
||||
setTimeout(() => setToast(null), 5000);
|
||||
};
|
||||
|
||||
const loadPricing = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
if (j.data) {
|
||||
const d = j.data as PricingData;
|
||||
(["starter", "pro", "business"] as const).forEach((pid) => {
|
||||
const m = d[pid].price_monthly;
|
||||
d[pid].price_yearly = computeYearlyFromMonthly(m);
|
||||
});
|
||||
setPricing(d);
|
||||
}
|
||||
if (j.stripe) setStripeStatus(j.stripe);
|
||||
} else if (res.status === 401) {
|
||||
showToast("err", "Session admin expirée. Reconnectez-vous.");
|
||||
} else {
|
||||
showToast("err", `Impossible de charger les tarifs (HTTP ${res.status}).`);
|
||||
}
|
||||
} catch {
|
||||
showToast("err", "Erreur réseau lors du chargement.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadPricing(); }, [loadPricing]);
|
||||
|
||||
const parseApiError = async (res: Response): Promise<string> => {
|
||||
const err: {
|
||||
detail?: string | Array<{ msg?: string; type?: string }>;
|
||||
message?: string;
|
||||
} = await res.json().catch(() => ({}));
|
||||
if (Array.isArray(err.detail)) {
|
||||
return err.detail.map((e) => e.msg || JSON.stringify(e)).join(" — ") || `Erreur HTTP ${res.status}`;
|
||||
}
|
||||
if (typeof err.detail === "string") return err.detail;
|
||||
if (err.message) return err.message;
|
||||
return `Erreur HTTP ${res.status}`;
|
||||
};
|
||||
|
||||
const savePricing = async () => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
if (!token) {
|
||||
showToast("err", "Session admin absente. Reconnectez-vous sur /admin/login.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const pid of ["starter", "pro", "business"] as const) {
|
||||
const m = pricing[pid].price_monthly;
|
||||
if (!Number.isFinite(m) || m < 0.01) {
|
||||
showToast(
|
||||
"err",
|
||||
`Prix mensuel invalide pour ${pricing[pid].name} : minimum 0,01 € (valeur actuelle : ${m}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
starter: {
|
||||
price_monthly: pricing.starter.price_monthly,
|
||||
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
|
||||
pricing.starter.stripe_price_id_monthly,
|
||||
),
|
||||
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
|
||||
pricing.starter.stripe_price_id_yearly,
|
||||
),
|
||||
},
|
||||
pro: {
|
||||
price_monthly: pricing.pro.price_monthly,
|
||||
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
|
||||
pricing.pro.stripe_price_id_monthly,
|
||||
),
|
||||
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
|
||||
pricing.pro.stripe_price_id_yearly,
|
||||
),
|
||||
},
|
||||
business: {
|
||||
price_monthly: pricing.business.price_monthly,
|
||||
stripe_price_id_monthly: sanitizeStripePriceIdForSave(
|
||||
pricing.business.stripe_price_id_monthly,
|
||||
),
|
||||
stripe_price_id_yearly: sanitizeStripePriceIdForSave(
|
||||
pricing.business.stripe_price_id_yearly,
|
||||
),
|
||||
},
|
||||
};
|
||||
// Include Stripe keys only if filled in
|
||||
if (stripeKeys.secret) body.stripe_secret_key = stripeKeys.secret;
|
||||
if (stripeKeys.publishable) body.stripe_publishable_key = stripeKeys.publishable;
|
||||
if (stripeKeys.webhook) body.stripe_webhook_secret = stripeKeys.webhook;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/pricing`, {
|
||||
method: "PUT",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast("ok", "✅ Tarifs enregistrés (fichier data/pricing_overrides.json). Les clés Stripe vont dans .env si renseignées.");
|
||||
setStripeKeys({ secret: "", publishable: "", webhook: "" });
|
||||
loadPricing();
|
||||
} else {
|
||||
const msg = await parseApiError(res);
|
||||
showToast("err", msg);
|
||||
}
|
||||
} catch {
|
||||
showToast("err", "Erreur réseau (vérifiez que le backend tourne et que l’URL API est correcte).");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const autoSetupStripe = async () => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
if (!token) {
|
||||
showToast("err", "Session admin absente. Reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
setSetupLoading(true);
|
||||
setSetupResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const j = await res.json();
|
||||
if (res.ok) {
|
||||
setSetupResult(j);
|
||||
if (j.errors?.length === 0) {
|
||||
showToast("ok", "✅ Produits Stripe créés et Price IDs enregistrés dans .env !");
|
||||
} else if (j.data && Object.keys(j.data).length > 0) {
|
||||
showToast("ok", `⚠️ Partiellement réussi. ${j.errors?.length} erreur(s).`);
|
||||
} else {
|
||||
showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe.");
|
||||
}
|
||||
loadPricing();
|
||||
} else {
|
||||
showToast("err", j.message || "Erreur lors de la configuration Stripe.");
|
||||
setSetupResult(j);
|
||||
}
|
||||
} catch {
|
||||
showToast("err", "Erreur réseau.");
|
||||
} finally {
|
||||
setSetupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => {
|
||||
setPricing((prev) => {
|
||||
const next = { ...prev[planId], [field]: value } as PlanConfig;
|
||||
if (field === "price_monthly") {
|
||||
const m = typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
next.price_yearly = computeYearlyFromMonthly(m);
|
||||
}
|
||||
return { ...prev, [planId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-600/20 rounded-lg flex items-center justify-center">
|
||||
<CreditCard className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Pricing & Stripe</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gérez les prix des forfaits et la configuration Stripe. Enregistrer ici applique les tarifs tout de suite (API publique, checkout) —{" "}
|
||||
<strong className="text-foreground/90">aucun redémarrage du serveur requis</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`flex items-start gap-3 p-4 rounded-xl border text-sm ${
|
||||
toast.type === "ok"
|
||||
? "bg-emerald-900/20 border-emerald-600/30 text-emerald-300"
|
||||
: "bg-red-900/20 border-red-600/30 text-red-300"
|
||||
}`}>
|
||||
{toast.type === "ok" ? <CheckCircle 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">{toast.text}</span>
|
||||
<button onClick={() => setToast(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stripe Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-violet-400" />
|
||||
Statut Stripe
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Clé secrète", ok: stripeStatus?.has_secret_key },
|
||||
{ label: "Clé publique", ok: stripeStatus?.has_publishable_key },
|
||||
{ label: "Webhook secret", ok: stripeStatus?.has_webhook_secret },
|
||||
].map(({ label, ok }) => (
|
||||
<div key={label} className={`rounded-lg border p-3 flex items-center gap-2 ${
|
||||
ok ? "border-emerald-600/30 bg-emerald-900/10" : "border-red-600/30 bg-red-900/10"
|
||||
}`}>
|
||||
{ok
|
||||
? <CheckCircle className="w-4 h-4 text-emerald-400 flex-shrink-0" />
|
||||
: <XCircle className="w-4 h-4 text-red-400 flex-shrink-0" />}
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Auto-setup button */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Configuration automatique Stripe</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.
|
||||
</p>
|
||||
{setupResult && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
|
||||
{Object.entries(setupResult.data || {}).map(([plan, info]: any) => (
|
||||
<div key={plan} className="flex items-center gap-2">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-400" />
|
||||
<span className="capitalize font-medium">{plan}</span>
|
||||
<span className="text-muted-foreground">— mensuel: <code>{info.monthly_price_id}</code></span>
|
||||
</div>
|
||||
))}
|
||||
{setupResult.errors?.map((e: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-red-400">
|
||||
<XCircle className="w-3 h-3" />
|
||||
<span>{e.plan}: {e.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={autoSetupStripe}
|
||||
disabled={setupLoading || !stripeStatus?.has_secret_key}
|
||||
className="bg-violet-600 hover:bg-violet-500 gap-2 flex-shrink-0"
|
||||
>
|
||||
{setupLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
{setupLoading ? "Configuration…" : "Créer automatiquement"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Manual Stripe keys */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="stripe-secret" className="text-xs text-muted-foreground">Clé secrète (sk_test_... ou sk_live_...)</Label>
|
||||
<Input
|
||||
id="stripe-secret"
|
||||
type="password"
|
||||
placeholder={stripeStatus?.has_secret_key ? "Clé déjà configurée — laisser vide pour garder" : "sk_test_..."}
|
||||
value={stripeKeys.secret}
|
||||
onChange={e => setStripeKeys(prev => ({ ...prev, secret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="stripe-pub" className="text-xs text-muted-foreground">Clé publique (pk_test_... ou pk_live_...)</Label>
|
||||
<Input
|
||||
id="stripe-pub"
|
||||
placeholder={stripeStatus?.has_publishable_key ? "Clé déjà configurée" : "pk_test_..."}
|
||||
value={stripeKeys.publishable}
|
||||
onChange={e => setStripeKeys(prev => ({ ...prev, publishable: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="stripe-webhook" className="text-xs text-muted-foreground">Webhook secret (whsec_...)</Label>
|
||||
<Input
|
||||
id="stripe-webhook"
|
||||
type="password"
|
||||
placeholder={stripeStatus?.has_webhook_secret ? "Secret déjà configuré" : "whsec_..."}
|
||||
value={stripeKeys.webhook}
|
||||
onChange={e => setStripeKeys(prev => ({ ...prev, webhook: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 flex items-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer" className="underline hover:text-foreground">
|
||||
Obtenir vos clés sur dashboard.stripe.com/apikeys
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plan pricing cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{(["starter", "pro", "business"] as const).map((planId) => {
|
||||
const plan = pricing[planId];
|
||||
const Icon = PLAN_ICONS[planId] || Sparkles;
|
||||
const hasMonthlyId = plan.stripe_price_id_monthly && !plan.stripe_price_id_monthly.includes("xxx");
|
||||
const hasYearlyId = plan.stripe_price_id_yearly && !plan.stripe_price_id_yearly.includes("xxx");
|
||||
|
||||
return (
|
||||
<Card key={planId} className={`${
|
||||
planId === "pro" ? "border-violet-500/30" :
|
||||
planId === "business" ? "border-emerald-500/30" : ""
|
||||
}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${
|
||||
planId === "pro" ? "text-violet-400" :
|
||||
planId === "business" ? "text-emerald-400" : "text-blue-400"
|
||||
}`} />
|
||||
{plan.name}
|
||||
{hasMonthlyId && hasYearlyId
|
||||
? <Badge className="ml-auto text-xs bg-emerald-900/30 text-emerald-300 border-emerald-500/30">✓ Stripe OK</Badge>
|
||||
: <Badge variant="outline" className="ml-auto text-xs text-amber-400 border-amber-500/30">Price IDs manquants</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Prices */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Prix mensuel (€)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={plan.price_monthly}
|
||||
onChange={e => updatePlan(planId, "price_monthly", parseFloat(e.target.value) || 0)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Prix annuel (€) — auto (−{ANNUAL_DISCOUNT_PERCENT} %)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
value={plan.price_yearly}
|
||||
className="h-8 text-sm bg-muted/40 cursor-not-allowed"
|
||||
aria-readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Facturation annuelle = 12 × mensuel × {YEARLY_DISCOUNT_FACTOR} (équivalent −{ANNUAL_DISCOUNT_PERCENT} % vs 12 mois au tarif mensuel). Le serveur impose cette règle à l’enregistrement et pour Stripe.
|
||||
</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stripe Price IDs */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KeyRound className="w-3 h-3" /> Price ID mensuel
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="price_..."
|
||||
value={plan.stripe_price_id_monthly}
|
||||
onChange={e => updatePlan(planId, "stripe_price_id_monthly", e.target.value)}
|
||||
className={`h-8 text-xs font-mono ${hasMonthlyId ? "border-emerald-500/30" : "border-amber-500/30"}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KeyRound className="w-3 h-3" /> Price ID annuel
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="price_..."
|
||||
value={plan.stripe_price_id_yearly}
|
||||
onChange={e => updatePlan(planId, "stripe_price_id_yearly", e.target.value)}
|
||||
className={`h-8 text-xs font-mono ${hasYearlyId ? "border-emerald-500/30" : "border-amber-500/30"}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Warning if Price IDs missing */}
|
||||
{["starter", "pro", "business"].some(p => {
|
||||
const plan = pricing[p as keyof PricingData];
|
||||
return !plan.stripe_price_id_monthly || plan.stripe_price_id_monthly.includes("xxx");
|
||||
}) && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl border border-amber-500/30 bg-amber-900/10 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-amber-300 font-medium">Price IDs Stripe manquants</p>
|
||||
<p className="text-amber-400/80 text-xs mt-1">
|
||||
Cliquez sur <strong>"Créer automatiquement"</strong> pour que le backend crée les produits dans Stripe et remplisse les IDs automatiquement. Ou entrez-les manuellement depuis votre{" "}
|
||||
<a href="https://dashboard.stripe.com/products" target="_blank" rel="noreferrer" className="underline">
|
||||
tableau de bord Stripe
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={savePricing} disabled={saving} size="lg" className="gap-2">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{saving ? "Sauvegarde…" : "Sauvegarder la configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface ProviderConfig {
|
||||
|
||||
interface SettingsConfig {
|
||||
google: ProviderConfig;
|
||||
google_cloud: ProviderConfig;
|
||||
deepl: ProviderConfig;
|
||||
openai: ProviderConfig;
|
||||
ollama: ProviderConfig;
|
||||
@@ -42,6 +43,7 @@ interface EnvInfo {
|
||||
openrouter_premium: boolean;
|
||||
zai: boolean;
|
||||
ollama: boolean;
|
||||
google_cloud: boolean;
|
||||
}
|
||||
|
||||
interface OllamaModel {
|
||||
@@ -52,6 +54,7 @@ interface OllamaModel {
|
||||
|
||||
const defaultConfig: SettingsConfig = {
|
||||
google: { enabled: true, timeout: 30, max_retries: 3 },
|
||||
google_cloud: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
|
||||
deepl: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
|
||||
openai: { enabled: false, api_key: "", timeout: 60, max_retries: 3 },
|
||||
ollama: { enabled: false, base_url: "http://localhost:11434", model: "llama3" },
|
||||
@@ -70,6 +73,7 @@ const defaultEnvInfo: EnvInfo = {
|
||||
openrouter_premium: false,
|
||||
zai: false,
|
||||
ollama: false,
|
||||
google_cloud: false,
|
||||
};
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
@@ -224,7 +228,7 @@ export default function AdminSettingsPage() {
|
||||
<div className="grid gap-4">
|
||||
<ProviderCard
|
||||
title="Google Translate"
|
||||
description="Tier gratuit : 500 000 caractères/mois. Aucune clé requise."
|
||||
description="Accès web non officiel via deep_translator. Aucune clé requise, usage raisonnable recommandé."
|
||||
enabled={config.google.enabled}
|
||||
onToggle={(enabled) => updateProvider("google", { enabled })}
|
||||
onTest={() => testProvider("google")}
|
||||
@@ -233,6 +237,35 @@ export default function AdminSettingsPage() {
|
||||
noApiKey
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
title="Google Cloud Translation (officiel)"
|
||||
description="API officielle Google Cloud. 500 000 car./mois offerts, puis ~$20/M car. Clé API sur console.cloud.google.com"
|
||||
enabled={config.google_cloud.enabled}
|
||||
onToggle={(enabled) => updateProvider("google_cloud", { enabled })}
|
||||
onTest={() => testProvider("google_cloud")}
|
||||
testResult={testResults.google_cloud ?? "idle"}
|
||||
testMessage={testMessages.google_cloud}
|
||||
envKeySet={envInfo.google_cloud}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="google-cloud-key">Clé API</Label>
|
||||
<Input
|
||||
id="google-cloud-key"
|
||||
type="password"
|
||||
placeholder={
|
||||
envInfo.google_cloud
|
||||
? "Clé configurée dans .env (laisser vide pour l'utiliser)"
|
||||
: "AIza..."
|
||||
}
|
||||
value={config.google_cloud.api_key || ""}
|
||||
onChange={(e) => updateProvider("google_cloud", { api_key: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Activez <code>Cloud Translation API</code> dans Google Cloud Console, puis créez une clé API restreinte à cette API.
|
||||
</p>
|
||||
</div>
|
||||
</ProviderCard>
|
||||
|
||||
<ProviderCard
|
||||
title="DeepL"
|
||||
description="Traduction professionnelle. Obtenez une clé sur deepl.com/pro-api"
|
||||
|
||||
@@ -32,7 +32,15 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, KeyRound, Loader2, Filter } from "lucide-react";
|
||||
import { Search, KeyRound, Loader2, Filter, Lock } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import type { AdminUser, PlanType } from "./types";
|
||||
import { PLAN_LABELS, PLAN_TIERS } from "./types";
|
||||
|
||||
@@ -41,8 +49,10 @@ interface UserTableProps {
|
||||
isLoading: boolean;
|
||||
onTierChange: (userId: string, plan: PlanType) => Promise<void>;
|
||||
onRevokeKeys: (userId: string, keyIds: string[]) => Promise<void>;
|
||||
onResetPassword: (userId: string, newPassword: string) => Promise<void>;
|
||||
isUpdating: boolean;
|
||||
isRevoking: boolean;
|
||||
isResettingPassword: boolean;
|
||||
}
|
||||
|
||||
type TierFilter = "all" | "free" | "pro";
|
||||
@@ -88,13 +98,19 @@ export function UserTable({
|
||||
isLoading,
|
||||
onTierChange,
|
||||
onRevokeKeys,
|
||||
onResetPassword,
|
||||
isUpdating,
|
||||
isRevoking,
|
||||
isResettingPassword,
|
||||
}: UserTableProps) {
|
||||
const toast = useToast();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all");
|
||||
const [revokedUsers, setRevokedUsers] = useState<Set<string>>(new Set());
|
||||
const [errorUserId, setErrorUserId] = useState<string | null>(null);
|
||||
const [pwdDialogUser, setPwdDialogUser] = useState<AdminUser | null>(null);
|
||||
const [newPwd, setNewPwd] = useState("");
|
||||
const [confirmPwd, setConfirmPwd] = useState("");
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
let result = users;
|
||||
@@ -210,6 +226,9 @@ export function UserTable({
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Statut
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Stockage
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Plan
|
||||
</TableHead>
|
||||
@@ -256,6 +275,24 @@ export function UserTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] font-normal ${
|
||||
user.storage === "json_file"
|
||||
? "border-amber-500/40 text-amber-600"
|
||||
: "border-muted-foreground/30 text-muted-foreground"
|
||||
}`}
|
||||
title={
|
||||
user.storage === "json_file"
|
||||
? "Compte uniquement dans data/users.json (legacy)"
|
||||
: "Compte en base SQLite / PostgreSQL"
|
||||
}
|
||||
>
|
||||
{user.storage === "json_file" ? "JSON" : "Base"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="py-2">
|
||||
<Select
|
||||
value={user.plan}
|
||||
@@ -321,31 +358,54 @@ export function UserTable({
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="pr-6 py-2 text-right">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 gap-1 px-2 text-[10px] ${
|
||||
justRevoked
|
||||
? "border-[oklch(0.59_0.16_145/0.3)] text-[oklch(0.45_0.12_145)]"
|
||||
: hasError
|
||||
? "border-destructive text-destructive"
|
||||
: "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
}`}
|
||||
onClick={() => handleRevokeKeys(user.id, apiKeyIds)}
|
||||
disabled={apiKeyIds.length === 0 || isRevoking || justRevoked}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
{justRevoked ? "Révoquées" : "Révoquer"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
{apiKeyIds.length === 0
|
||||
? "Aucune clé active"
|
||||
: `Révoquer ${apiKeyIds.length} clé${apiKeyIds.length > 1 ? "s" : ""} active${apiKeyIds.length > 1 ? "s" : ""}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-[10px] border-primary/30 text-primary hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
setPwdDialogUser(user);
|
||||
setNewPwd("");
|
||||
setConfirmPwd("");
|
||||
}}
|
||||
disabled={isResettingPassword}
|
||||
>
|
||||
<Lock className="size-3" />
|
||||
MDP
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
Définir un nouveau mot de passe (sans e-mail)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 gap-1 px-2 text-[10px] ${
|
||||
justRevoked
|
||||
? "border-[oklch(0.59_0.16_145/0.3)] text-[oklch(0.45_0.12_145)]"
|
||||
: hasError
|
||||
? "border-destructive text-destructive"
|
||||
: "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
}`}
|
||||
onClick={() => handleRevokeKeys(user.id, apiKeyIds)}
|
||||
disabled={apiKeyIds.length === 0 || isRevoking || justRevoked}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
{justRevoked ? "Révoquées" : "Révoquer"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
{apiKeyIds.length === 0
|
||||
? "Aucune clé active"
|
||||
: `Révoquer ${apiKeyIds.length} clé${apiKeyIds.length > 1 ? "s" : ""} active${apiKeyIds.length > 1 ? "s" : ""}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -354,7 +414,7 @@ export function UserTable({
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{searchQuery || tierFilter !== "all"
|
||||
@@ -378,6 +438,69 @@ export function UserTable({
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={!!pwdDialogUser} onOpenChange={(o) => !o && setPwdDialogUser(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouveau mot de passe</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pwdDialogUser?.email} — minimum 8 caractères.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Nouveau mot de passe</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPwd}
|
||||
onChange={(e) => setNewPwd(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Confirmation</label>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPwd}
|
||||
onChange={(e) => setConfirmPwd(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setPwdDialogUser(null)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isResettingPassword || newPwd.length < 8}
|
||||
onClick={async () => {
|
||||
if (!pwdDialogUser) return;
|
||||
if (newPwd !== confirmPwd) {
|
||||
toast.error({ title: "Les mots de passe ne correspondent pas" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onResetPassword(pwdDialogUser.id, newPwd);
|
||||
toast.success({ title: "Mot de passe mis à jour" });
|
||||
setPwdDialogUser(null);
|
||||
setNewPwd("");
|
||||
setConfirmPwd("");
|
||||
} catch (e) {
|
||||
toast.error({
|
||||
title: "Échec",
|
||||
description: e instanceof Error ? e.message : "Erreur",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isResettingPassword ? <Loader2 className="size-4 animate-spin" /> : "Enregistrer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Users } from "lucide-react";
|
||||
import { useAdminUsers } from "./useAdminUsers";
|
||||
import { useAdminResetPassword } from "./useAdminResetPassword";
|
||||
import { useUpdateUserTier } from "./useUpdateUserTier";
|
||||
import { useRevokeApiKey } from "./useRevokeApiKey";
|
||||
import { UserStats } from "./UserStats";
|
||||
@@ -13,6 +14,7 @@ export default function AdminUsersPage() {
|
||||
const { users, total, isLoading, error, refetch } = useAdminUsers();
|
||||
const { updateTier, isUpdating } = useUpdateUserTier();
|
||||
const { revokeKey, isRevoking } = useRevokeApiKey();
|
||||
const { resetPassword, isResetting } = useAdminResetPassword();
|
||||
const toast = useToast();
|
||||
|
||||
const handleTierChange = async (userId: string, plan: PlanType) => {
|
||||
@@ -106,8 +108,13 @@ export default function AdminUsersPage() {
|
||||
isLoading={isLoading}
|
||||
onTierChange={handleTierChange}
|
||||
onRevokeKeys={handleRevokeKeys}
|
||||
onResetPassword={async (userId, pwd) => {
|
||||
await resetPassword(userId, pwd);
|
||||
await refetch();
|
||||
}}
|
||||
isUpdating={isUpdating}
|
||||
isRevoking={isRevoking}
|
||||
isResettingPassword={isResetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface AdminUser {
|
||||
plan_limits: PlanLimits;
|
||||
api_keys_count?: number;
|
||||
api_key_ids?: string[];
|
||||
/** Présent si le backend expose la source du compte */
|
||||
storage?: "database" | "json_file";
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
|
||||
47
frontend/src/app/admin/users/useAdminResetPassword.ts
Normal file
47
frontend/src/app/admin/users/useAdminResetPassword.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { ADMIN_TIMEOUT_MS } from "./useAdminUsers";
|
||||
|
||||
export function useAdminResetPassword() {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const resetPassword = useCallback(async (userId: string, newPassword: string) => {
|
||||
const token = useTranslationStore.getState().settings.adminToken;
|
||||
if (!token) {
|
||||
throw new Error("Session admin requise");
|
||||
}
|
||||
setIsResetting(true);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/users/${encodeURIComponent(userId)}/password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
const detail = j.detail;
|
||||
const msg =
|
||||
typeof detail === "string"
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail[0]?.msg
|
||||
: j.message;
|
||||
throw new Error(msg || `Erreur HTTP ${res.status}`);
|
||||
}
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { resetPassword, isResetting };
|
||||
}
|
||||
@@ -18,15 +18,18 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { useUser } from './useUser';
|
||||
import { useLogout } from './useLogout';
|
||||
import { getNavItems } from './constants';
|
||||
import { getInitials } from './utils';
|
||||
import { getInitials, translateTier } from './utils';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export function DashboardHeader() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { data: user, isLoading } = useUser();
|
||||
const { logout } = useLogout();
|
||||
const { t } = useI18n();
|
||||
|
||||
const navItems = getNavItems(user?.tier === 'pro');
|
||||
const navItems = getNavItems(['pro', 'business', 'enterprise'].includes(user?.tier ?? ''));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -37,7 +40,7 @@ export function DashboardHeader() {
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-label={t('dashboard.header.toggleMenu')}
|
||||
>
|
||||
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
|
||||
</Button>
|
||||
@@ -47,35 +50,42 @@ export function DashboardHeader() {
|
||||
<div className="flex size-6 items-center justify-center rounded-md bg-foreground">
|
||||
<Languages className="size-3 text-background" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground">Office Translator</span>
|
||||
<span className="text-sm font-semibold text-foreground">{t('auth.brandName')}</span>
|
||||
</div>
|
||||
|
||||
{/* Page title - desktop */}
|
||||
<div className="hidden items-center gap-3 lg:flex">
|
||||
<h1 className="text-sm font-semibold text-foreground">Dashboard</h1>
|
||||
<h1 className="text-sm font-semibold text-foreground">{t('dashboard.header.title')}</h1>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-sm text-muted-foreground">Manage your API and translation settings</span>
|
||||
<span className="text-sm text-muted-foreground">{t('dashboard.header.subtitle')}</span>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
{!isLoading && user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'border border-accent/20',
|
||||
user.tier === 'pro' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{user.tier === 'pro' ? 'Pro Plan' : 'Free Plan'}
|
||||
</Badge>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
{!isLoading && user && (
|
||||
<>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'border border-accent/20',
|
||||
user.tier === 'free' || !user.tier
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-accent/10 text-accent'
|
||||
)}
|
||||
>
|
||||
{translateTier(t, user.tier)}
|
||||
</Badge>
|
||||
<Link href="/dashboard/profile" title={t('dashboard.header.profileTitle')}>
|
||||
<Avatar className="size-8 cursor-pointer ring-2 ring-transparent hover:ring-accent/40 transition-all">
|
||||
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile navigation drawer */}
|
||||
@@ -97,13 +107,17 @@ export function DashboardHeader() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Separator className="my-2" />
|
||||
{!isLoading && user && (
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<Link
|
||||
href="/dashboard/profile"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
|
||||
{getInitials(user.name)}
|
||||
@@ -111,31 +125,31 @@ export function DashboardHeader() {
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{user.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs w-fit',
|
||||
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
|
||||
)}
|
||||
>
|
||||
{user.tier === 'pro' ? 'Pro' : 'Free'}
|
||||
{translateTier(t, user.tier)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<LogOut className="size-4 shrink-0" />
|
||||
Sign out
|
||||
{t('dashboard.sidebar.signOut')}
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4 shrink-0" />
|
||||
Back to home
|
||||
{t('dashboard.sidebar.backHome')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -11,14 +11,17 @@ import { Button } from '@/components/ui/button';
|
||||
import { useUser } from './useUser';
|
||||
import { useLogout } from './useLogout';
|
||||
import { getNavItems } from './constants';
|
||||
import { getInitials } from './utils';
|
||||
import { getInitials, translateTier } from './utils';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { data: user, isLoading } = useUser();
|
||||
const { logout } = useLogout();
|
||||
const { t } = useI18n();
|
||||
|
||||
const navItems = getNavItems(user?.tier === 'pro');
|
||||
const navItems = getNavItems(['pro', 'business', 'enterprise'].includes(user?.tier ?? ''));
|
||||
|
||||
return (
|
||||
<aside className="hidden w-64 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
|
||||
@@ -27,9 +30,7 @@ export function DashboardSidebar() {
|
||||
<div className="flex size-7 items-center justify-center rounded-md bg-foreground">
|
||||
<Languages className="size-3.5 text-background" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight text-foreground">
|
||||
Office Translator
|
||||
</span>
|
||||
<span className="text-sm font-semibold tracking-tight text-foreground">{t('auth.brandName')}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -50,7 +51,7 @@ export function DashboardSidebar() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -60,32 +61,36 @@ export function DashboardSidebar() {
|
||||
|
||||
{/* User section */}
|
||||
{!isLoading && user && (
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<Avatar className="size-8">
|
||||
<div className="flex items-center gap-2.5 px-4 py-3">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-none text-foreground">{user.name}</span>
|
||||
<span className="text-xs leading-none text-muted-foreground">{user.email}</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium leading-none text-foreground">{user.name}</span>
|
||||
<span className="truncate text-xs leading-none text-muted-foreground">{user.email}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'mt-0.5 w-fit text-xs',
|
||||
user.tier !== 'free' && user.tier && 'border border-accent/20 bg-accent/10 text-accent'
|
||||
)}
|
||||
>
|
||||
{translateTier(t, user.tier)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'ml-auto text-xs',
|
||||
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
|
||||
)}
|
||||
>
|
||||
{user.tier === 'pro' ? 'Pro' : 'Free'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-3 py-3">
|
||||
{/* Actions */}
|
||||
<div className="px-3 py-3 space-y-1">
|
||||
<div className="flex items-center justify-between px-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.sidebar.theme')}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -93,16 +98,12 @@ export function DashboardSidebar() {
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="size-3.5" />
|
||||
Sign out
|
||||
{t('dashboard.sidebar.signOut')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Back to homepage */}
|
||||
<div className="px-3 py-3">
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-muted-foreground" asChild>
|
||||
<Link href="/">
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back to home
|
||||
{t('dashboard.sidebar.backHome')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { LayoutDashboard, FileText, Key, BookText, type LucideIcon } from 'lucide-react';
|
||||
import { FileText, Key, BookText, User, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
proOnly?: boolean;
|
||||
}
|
||||
|
||||
export const baseNavItems: NavItem[] = [
|
||||
{ label: 'Overview', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Translate', href: '/dashboard/translate', icon: FileText },
|
||||
{ label: 'API Keys', href: '/dashboard/api-keys', icon: Key },
|
||||
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
||||
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
||||
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key },
|
||||
];
|
||||
|
||||
export const proNavItem: NavItem = {
|
||||
label: 'Glossaries',
|
||||
href: '/dashboard/glossaries',
|
||||
export const proNavItem: NavItem = {
|
||||
labelKey: 'dashboard.nav.glossaries',
|
||||
href: '/dashboard/glossaries',
|
||||
icon: BookText,
|
||||
proOnly: true
|
||||
proOnly: true,
|
||||
};
|
||||
|
||||
export function getNavItems(isPro: boolean): NavItem[] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,94 +12,473 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TermEditor } from './TermEditor';
|
||||
import { parseFileToTerms } from './csvUtils';
|
||||
import { useGlossaryTemplates } from './useGlossaries';
|
||||
import type { GlossaryTermInput } from './types';
|
||||
import type { GlossaryTemplate } from './useGlossaries';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
Upload,
|
||||
FileText,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
X,
|
||||
Scale,
|
||||
Cpu,
|
||||
TrendingUp,
|
||||
HeartPulse,
|
||||
Megaphone,
|
||||
Users,
|
||||
FlaskConical,
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CreateGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||
onImportTemplate: (templateId: string, name?: string) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
isImportingTemplate: boolean;
|
||||
}
|
||||
|
||||
const TEMPLATE_ICONS: Record<string, React.ReactNode> = {
|
||||
legal: <Scale className="size-5" />,
|
||||
technology: <Cpu className="size-5" />,
|
||||
finance: <TrendingUp className="size-5" />,
|
||||
medical: <HeartPulse className="size-5" />,
|
||||
marketing: <Megaphone className="size-5" />,
|
||||
hr: <Users className="size-5" />,
|
||||
scientific: <FlaskConical className="size-5" />,
|
||||
ecommerce: <ShoppingCart className="size-5" />,
|
||||
};
|
||||
|
||||
const TEMPLATE_COLORS: Record<string, string> = {
|
||||
legal: 'bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100',
|
||||
technology: 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100',
|
||||
finance: 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100',
|
||||
medical: 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100',
|
||||
marketing: 'bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100',
|
||||
hr: 'bg-teal-50 text-teal-700 border-teal-200 hover:bg-teal-100',
|
||||
scientific: 'bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100',
|
||||
ecommerce: 'bg-pink-50 text-pink-700 border-pink-200 hover:bg-pink-100',
|
||||
};
|
||||
|
||||
type FileStatus = 'idle' | 'parsing' | 'success' | 'error';
|
||||
|
||||
const MAX_FILE_SIZE_MB = 5;
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
onSelect,
|
||||
isLoading,
|
||||
termsLabel,
|
||||
}: {
|
||||
template: GlossaryTemplate;
|
||||
onSelect: (t: GlossaryTemplate) => void;
|
||||
isLoading: boolean;
|
||||
termsLabel: string;
|
||||
}) {
|
||||
const icon = TEMPLATE_ICONS[template.id] ?? <BookOpen className="size-5" />;
|
||||
const colorClass = TEMPLATE_COLORS[template.id] ?? 'bg-muted text-foreground border-border hover:bg-muted/80';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(template)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
{template.name.split(' - ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs font-normal">
|
||||
{template.terms_count} {termsLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs opacity-75 line-clamp-2">{template.description}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FileUploadZone({
|
||||
onTermsParsed,
|
||||
disabled,
|
||||
}: {
|
||||
onTermsParsed: (terms: GlossaryTermInput[], filename: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [status, setStatus] = useState<FileStatus>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [parsedFile, setParsedFile] = useState<{ name: string; count: number } | null>(null);
|
||||
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
const allowed = ['csv', 'xlsx', 'xls', 'ods', 'txt', 'tsv'];
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
setStatus('error');
|
||||
setErrorMsg(t('glossaries.dialog.errorFormat'));
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
||||
setStatus('error');
|
||||
setErrorMsg(t('glossaries.dialog.errorSize', { max: String(MAX_FILE_SIZE_MB) }));
|
||||
return;
|
||||
}
|
||||
setStatus('parsing');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const terms = await parseFileToTerms(file);
|
||||
if (terms.length === 0) {
|
||||
setStatus('error');
|
||||
setErrorMsg(t('glossaries.dialog.errorEmpty'));
|
||||
return;
|
||||
}
|
||||
setStatus('success');
|
||||
setParsedFile({ name: file.name, count: terms.length });
|
||||
onTermsParsed(terms, file.name);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setErrorMsg(t('glossaries.dialog.errorRead'));
|
||||
}
|
||||
}, [onTermsParsed, t]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) processFile(file);
|
||||
}, [processFile]);
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) processFile(file);
|
||||
e.target.value = '';
|
||||
}, [processFile]);
|
||||
|
||||
const reset = () => {
|
||||
setStatus('idle');
|
||||
setParsedFile(null);
|
||||
setErrorMsg('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer',
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/30',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
status === 'success' && 'border-green-400 bg-green-50',
|
||||
status === 'error' && 'border-destructive/50 bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{status === 'parsing' && (
|
||||
<>
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{t('glossaries.dialog.parsing')}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && parsedFile && (
|
||||
<>
|
||||
<CheckCircle2 className="size-8 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-700">
|
||||
{t('glossaries.dialog.termsImported', { count: String(parsedFile.count) })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{parsedFile.name}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); reset(); }}
|
||||
className="gap-1.5 text-xs"
|
||||
>
|
||||
<X className="size-3.5" /> {t('glossaries.dialog.changeFile')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">{errorMsg}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); reset(); }}
|
||||
className="gap-1.5 text-xs"
|
||||
>
|
||||
<X className="size-3.5" /> {t('glossaries.dialog.retry')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'idle' && (
|
||||
<>
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('glossaries.dialog.dropTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('glossaries.dialog.dropOr')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('glossaries.dialog.dropFormats')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium">{t('glossaries.dialog.formatTitle')}</p>
|
||||
<p>{t('glossaries.dialog.formatDesc')}</p>
|
||||
<div className="font-mono bg-background rounded border px-2 py-1 mt-1">
|
||||
<div className="text-muted-foreground">source,target</div>
|
||||
<div>server,server</div>
|
||||
<div>database,database</div>
|
||||
</div>
|
||||
<p className="mt-1">{t('glossaries.dialog.formatNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateGlossaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
onImportTemplate,
|
||||
isCreating,
|
||||
isImportingTemplate,
|
||||
}: CreateGlossaryDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'file' | 'manual'>('templates');
|
||||
const [name, setName] = useState('');
|
||||
const [nameAutoFilled, setNameAutoFilled] = useState(false);
|
||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
||||
const [fileTerms, setFileTerms] = useState<GlossaryTermInput[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<GlossaryTemplate | null>(null);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
|
||||
|
||||
await onCreate({
|
||||
name: name.trim(),
|
||||
terms: validTerms,
|
||||
});
|
||||
|
||||
const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates();
|
||||
|
||||
const isProcessing = isCreating || isImportingTemplate;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setName('');
|
||||
setNameAutoFilled(false);
|
||||
setTerms([{ source: '', target: '' }]);
|
||||
}, [name, terms, onCreate]);
|
||||
setFileTerms([]);
|
||||
setSelectedTemplate(null);
|
||||
setActiveTab('templates');
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setName('');
|
||||
setTerms([{ source: '', target: '' }]);
|
||||
}
|
||||
if (!newOpen) reset();
|
||||
onOpenChange(newOpen);
|
||||
}, [onOpenChange]);
|
||||
}, [onOpenChange, reset]);
|
||||
|
||||
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
||||
const handleTemplateSelect = useCallback((template: GlossaryTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
if (!name || nameAutoFilled) {
|
||||
setName(template.name.split(' - ')[0]);
|
||||
setNameAutoFilled(true);
|
||||
}
|
||||
}, [name, nameAutoFilled]);
|
||||
|
||||
const handleFileTermsParsed = useCallback((parsed: GlossaryTermInput[], filename: string) => {
|
||||
setFileTerms(parsed);
|
||||
if (!name || nameAutoFilled) {
|
||||
const baseName = filename.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
|
||||
setName(baseName);
|
||||
setNameAutoFilled(true);
|
||||
}
|
||||
}, [name, nameAutoFilled]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
if (activeTab === 'templates' && selectedTemplate) {
|
||||
await onImportTemplate(selectedTemplate.id, name.trim());
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const termsToSave = activeTab === 'file'
|
||||
? fileTerms
|
||||
: terms.filter(t => t.source.trim() && t.target.trim());
|
||||
await onCreate({ name: name.trim(), terms: termsToSave });
|
||||
reset();
|
||||
}, [activeTab, selectedTemplate, name, fileTerms, terms, onCreate, onImportTemplate, reset]);
|
||||
|
||||
const canSubmit = (() => {
|
||||
if (!name.trim() || isProcessing) return false;
|
||||
if (activeTab === 'templates') return !!selectedTemplate;
|
||||
if (activeTab === 'file') return fileTerms.length > 0;
|
||||
return terms.some(t => t.source.trim() && t.target.trim());
|
||||
})();
|
||||
|
||||
const submitLabel = (() => {
|
||||
if (isProcessing) {
|
||||
return activeTab === 'templates'
|
||||
? t('glossaries.dialog.importing')
|
||||
: t('glossaries.dialog.creating');
|
||||
}
|
||||
if (activeTab === 'templates') {
|
||||
return selectedTemplate
|
||||
? t('glossaries.dialog.importBtn', { count: String(selectedTemplate.terms_count) })
|
||||
: t('glossaries.dialog.selectPrompt');
|
||||
}
|
||||
if (activeTab === 'file') {
|
||||
return fileTerms.length > 0
|
||||
? t('glossaries.dialog.importBtn', { count: String(fileTerms.length) })
|
||||
: t('glossaries.dialog.dropTitle');
|
||||
}
|
||||
const count = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
||||
return count > 0
|
||||
? t('glossaries.dialog.createBtn', { count: String(count) })
|
||||
: t('glossaries.dialog.createEmpty');
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Glossary</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a glossary with custom terminology for your translations.
|
||||
</DialogDescription>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t('glossaries.dialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('glossaries.dialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="glossary-name">Glossary Name</Label>
|
||||
<Input
|
||||
id="glossary-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Technical Terms FR-EN"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 space-y-2 pt-2">
|
||||
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
|
||||
<Input
|
||||
id="glossary-name"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
|
||||
placeholder={t('glossaries.dialog.namePlaceholder')}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Terms ({validTermsCount} valid)</Label>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||
className="flex flex-col flex-1 min-h-0 mt-4"
|
||||
>
|
||||
<TabsList className="shrink-0 w-full grid grid-cols-3">
|
||||
<TabsTrigger value="templates" className="gap-1.5 text-xs">
|
||||
<BookOpen className="size-3.5" />
|
||||
{t('glossaries.dialog.tabTemplates')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="file" className="gap-1.5 text-xs">
|
||||
<FileText className="size-3.5" />
|
||||
{t('glossaries.dialog.tabFile')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manual" className="gap-1.5 text-xs">
|
||||
<PenLine className="size-3.5" />
|
||||
{t('glossaries.dialog.tabManual')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="templates" className="flex-1 overflow-y-auto mt-4 space-y-3">
|
||||
{isLoadingTemplates ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('glossaries.dialog.templatesDesc')}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={cn(
|
||||
'relative',
|
||||
selectedTemplate?.id === template.id && 'ring-2 ring-primary ring-offset-1 rounded-lg'
|
||||
)}
|
||||
>
|
||||
{selectedTemplate?.id === template.id && (
|
||||
<CheckCircle2 className="absolute -top-1.5 -right-1.5 size-4 text-primary bg-background rounded-full z-10" />
|
||||
)}
|
||||
<TemplateCard
|
||||
template={template}
|
||||
onSelect={handleTemplateSelect}
|
||||
isLoading={isProcessing}
|
||||
termsLabel={t('glossaries.dialog.terms')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{templates.length === 0 && !isLoadingTemplates && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{t('glossaries.dialog.templatesEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="file" className="flex-1 overflow-y-auto mt-4">
|
||||
<FileUploadZone
|
||||
onTermsParsed={handleFileTermsParsed}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="flex-1 overflow-y-auto mt-4">
|
||||
<TermEditor
|
||||
terms={terms}
|
||||
onChange={setTerms}
|
||||
disabled={isCreating}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0 pt-4 border-t mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isCreating}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
{t('glossaries.dialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !name.trim()}
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Glossary'}
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{isProcessing && <Loader2 className="size-3.5 animate-spin mr-1.5" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BookText, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { GlossaryListItem } from './types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface GlossaryCardProps {
|
||||
glossary: GlossaryListItem;
|
||||
@@ -20,6 +21,7 @@ export const GlossaryCard = memo(function GlossaryCard({
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}: GlossaryCardProps) {
|
||||
const { t, locale } = useI18n();
|
||||
const handleEdit = useCallback(() => {
|
||||
onEdit(glossary.id);
|
||||
}, [glossary.id, onEdit]);
|
||||
@@ -28,7 +30,7 @@ export const GlossaryCard = memo(function GlossaryCard({
|
||||
onDelete(glossary.id, glossary.name);
|
||||
}, [glossary.id, glossary.name, onDelete]);
|
||||
|
||||
const formattedDate = new Date(glossary.created_at).toLocaleDateString('en-US', {
|
||||
const formattedDate = new Date(glossary.created_at).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -46,10 +48,10 @@ export const GlossaryCard = memo(function GlossaryCard({
|
||||
<h3 className="font-medium text-foreground truncate">{glossary.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{glossary.terms_count} {glossary.terms_count === 1 ? 'term' : 'terms'}
|
||||
{glossary.terms_count} {glossary.terms_count === 1 ? t('glossaries.card.term') : t('glossaries.card.terms')}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {formattedDate}
|
||||
{t('glossaries.card.created')} {formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, Plus, Trash2 } from 'lucide-react';
|
||||
import type { GlossaryTermInput, GlossaryTermInputWithId } from './types';
|
||||
import { MAX_TERMS_PER_GLOSSARY, generateTermId } from './types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface TermEditorProps {
|
||||
terms: GlossaryTermInput[];
|
||||
@@ -25,6 +26,7 @@ export const TermEditor = memo(function TermEditor({
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: TermEditorProps) {
|
||||
const { t } = useI18n();
|
||||
// Generate stable keys for current terms
|
||||
const termKeys = useMemo(() => {
|
||||
return terms.map((term, index) => getTermKey(term, index));
|
||||
@@ -51,11 +53,11 @@ export const TermEditor = memo(function TermEditor({
|
||||
<div className="space-y-3">
|
||||
<div className="mb-2 grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2 px-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Source Term
|
||||
{t('glossaries.termEditor.sourceTerm')}
|
||||
</span>
|
||||
<span />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Target Translation
|
||||
{t('glossaries.termEditor.targetTranslation')}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
@@ -107,12 +109,12 @@ export const TermEditor = memo(function TermEditor({
|
||||
className="mt-3 gap-1.5 border-dashed"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add Term
|
||||
{t('glossaries.termEditor.addTerm')}
|
||||
</Button>
|
||||
|
||||
|
||||
{maxTermsReached && (
|
||||
<p className="text-xs text-amber-600">
|
||||
Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary reached.
|
||||
{t('glossaries.termEditor.maxReached', { max: String(MAX_TERMS_PER_GLOSSARY) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,47 @@ export function parseCsvToTerms(csvText: string): GlossaryTermInput[] {
|
||||
return terms;
|
||||
}
|
||||
|
||||
export async function parseXlsxToTerms(file: File): Promise<GlossaryTermInput[]> {
|
||||
const { read, utils } = await import('xlsx');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = read(buffer, { type: 'array' });
|
||||
|
||||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
if (!firstSheet) return [];
|
||||
|
||||
const rows: string[][] = utils.sheet_to_json(firstSheet, { header: 1, defval: '' });
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Detect header row
|
||||
const firstRow = rows[0].map(c => String(c).toLowerCase().trim());
|
||||
const hasHeader =
|
||||
firstRow.some(c => c.includes('source') || c === 'src') &&
|
||||
firstRow.some(c => c.includes('target') || c === 'tgt' || c.includes('traduction') || c.includes('cible'));
|
||||
|
||||
const dataRows = hasHeader ? rows.slice(1) : rows;
|
||||
|
||||
const terms: GlossaryTermInput[] = [];
|
||||
for (const row of dataRows) {
|
||||
const source = String(row[0] ?? '').trim();
|
||||
const target = String(row[1] ?? '').trim();
|
||||
if (source && target) {
|
||||
terms.push({ source, target });
|
||||
}
|
||||
}
|
||||
|
||||
return terms;
|
||||
}
|
||||
|
||||
export async function parseFileToTerms(file: File): Promise<GlossaryTermInput[]> {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'xlsx' || ext === 'xls' || ext === 'ods') {
|
||||
return parseXlsxToTerms(file);
|
||||
}
|
||||
// CSV / TSV / TXT
|
||||
const text = await file.text();
|
||||
return parseCsvToTerms(text);
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useGlossaries, useGlossary } from './useGlossaries';
|
||||
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
|
||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||
@@ -16,6 +17,7 @@ import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
export default function GlossariesPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const {
|
||||
glossaries,
|
||||
@@ -24,9 +26,11 @@ export default function GlossariesPage() {
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
isImportingTemplate,
|
||||
createGlossary,
|
||||
updateGlossary,
|
||||
deleteGlossary,
|
||||
importTemplate,
|
||||
} = useGlossaries();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -62,14 +66,34 @@ export default function GlossariesPage() {
|
||||
await createGlossary(data);
|
||||
setCreateDialogOpen(false);
|
||||
toast({
|
||||
title: 'Glossary created',
|
||||
description: `"${data.name}" has been created successfully.`,
|
||||
title: t('glossaries.toast.created'),
|
||||
description: t('glossaries.toast.createdDesc', { name: data.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to create glossary. Please try again.',
|
||||
title: t('glossaries.toast.error'),
|
||||
description: t('glossaries.toast.errorCreate'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportTemplate = async (templateId: string, name?: string) => {
|
||||
try {
|
||||
await importTemplate(templateId, name);
|
||||
setCreateDialogOpen(false);
|
||||
toast({
|
||||
title: t('glossaries.toast.imported'),
|
||||
description: name
|
||||
? t('glossaries.toast.importedDesc', { name })
|
||||
: t('glossaries.toast.importedDesc', { name: templateId }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('glossaries.toast.error'),
|
||||
description: t('glossaries.toast.errorImport'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -81,14 +105,14 @@ export default function GlossariesPage() {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedGlossary(null);
|
||||
toast({
|
||||
title: 'Glossary updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
title: t('glossaries.toast.updated'),
|
||||
description: t('glossaries.toast.updatedDesc', { name: data.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to update glossary. Please try again.',
|
||||
title: t('glossaries.toast.error'),
|
||||
description: t('glossaries.toast.errorUpdate'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -101,14 +125,14 @@ export default function GlossariesPage() {
|
||||
setDeleteDialogOpen(false);
|
||||
setGlossaryToDelete(null);
|
||||
toast({
|
||||
title: 'Glossary deleted',
|
||||
description: 'The glossary has been deleted successfully.',
|
||||
title: t('glossaries.toast.deleted'),
|
||||
description: t('glossaries.toast.deletedDesc'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to delete glossary. Please try again.',
|
||||
title: t('glossaries.toast.error'),
|
||||
description: t('glossaries.toast.errorDelete'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -131,10 +155,8 @@ export default function GlossariesPage() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Glossaries</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage custom terminology for your LLM translations.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('glossaries.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('glossaries.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -144,8 +166,8 @@ export default function GlossariesPage() {
|
||||
<BookText className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Your Glossaries</CardTitle>
|
||||
<CardDescription>Create and manage glossaries for consistent translations</CardDescription>
|
||||
<CardTitle className="text-base">{t('glossaries.yourGlossaries')}</CardTitle>
|
||||
<CardDescription>{t('glossaries.yourGlossariesDesc')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -154,11 +176,9 @@ export default function GlossariesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{total} glossarie{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define term pairs to customize your LLM translations
|
||||
{t(total !== 1 ? 'glossaries.count_other' : 'glossaries.count_one', { count: String(total) })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('glossaries.defineTerms')}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
@@ -166,17 +186,15 @@ export default function GlossariesPage() {
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Create New Glossary
|
||||
{t('glossaries.createNew')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{glossaries.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<BookText className="size-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No glossaries yet</p>
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Create your first glossary to customize translations
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t('glossaries.empty')}</p>
|
||||
<p className="text-sm text-muted-foreground/80">{t('glossaries.emptyDesc')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -198,15 +216,11 @@ export default function GlossariesPage() {
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">About Glossaries</CardTitle>
|
||||
<CardTitle className="text-sm">{t('glossaries.aboutTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
Glossaries let you define custom terminology for your translations. When using LLM translation modes, your terms will be applied to ensure consistent translations.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Format:</strong> Each term has a source (original) and target (translation) pair.
|
||||
</p>
|
||||
<p>{t('glossaries.aboutDesc')}</p>
|
||||
<p><strong>{t('glossaries.aboutFormat')}</strong></p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -214,7 +228,9 @@ export default function GlossariesPage() {
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreate={handleCreateGlossary}
|
||||
onImportTemplate={handleImportTemplate}
|
||||
isCreating={isCreating}
|
||||
isImportingTemplate={isImportingTemplate}
|
||||
/>
|
||||
|
||||
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (
|
||||
|
||||
@@ -22,6 +22,21 @@ export type GlossaryErrorCode =
|
||||
| 'INVALID_GLOSSARY_ID'
|
||||
| 'UNAUTHORIZED';
|
||||
|
||||
export interface GlossaryTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source_lang: string;
|
||||
target_lang: string;
|
||||
terms_count: number;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface GlossaryTemplatesResponse {
|
||||
data: GlossaryTemplate[];
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
export interface GlossaryError {
|
||||
status: number;
|
||||
code: GlossaryErrorCode;
|
||||
@@ -103,6 +118,24 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
return deleteMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const importTemplateMutation = useMutation({
|
||||
mutationFn: async ({ templateId, name }: { templateId: string; name?: string }): Promise<Glossary> => {
|
||||
const params = new URLSearchParams({ template_id: templateId });
|
||||
if (name) params.set('name', name);
|
||||
const response = await apiClient.post<GlossaryDetailResponse>(
|
||||
`/api/v1/glossaries/import?${params.toString()}`
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const importTemplate = async (templateId: string, name?: string) => {
|
||||
return importTemplateMutation.mutateAsync({ templateId, name });
|
||||
};
|
||||
|
||||
const parseError = (error: Error | null): GlossaryError | null => {
|
||||
if (!error) return null;
|
||||
|
||||
@@ -145,9 +178,30 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
isImportingTemplate: importTemplateMutation.isPending,
|
||||
importTemplateError: importTemplateMutation.error,
|
||||
parseCreateError: () => parseError(createMutation.error),
|
||||
parseUpdateError: () => parseError(updateMutation.error),
|
||||
parseDeleteError: () => parseError(deleteMutation.error),
|
||||
importTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGlossaryTemplates() {
|
||||
const { data, isLoading, error } = useQuery<GlossaryTemplatesResponse, ApiClientError>({
|
||||
queryKey: ['glossary-templates'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // templates rarely change
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
templates: data?.data ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,102 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FileText, Key, BookText, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useUser } from './useUser';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* /dashboard — point d'entrée après connexion.
|
||||
*
|
||||
* Unique rôle : synchroniser le paiement Stripe si `session_id` est présent
|
||||
* dans l'URL (retour depuis Stripe Checkout), puis rediriger vers /dashboard/translate.
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const { data: user, isLoading } = useUser();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const checkoutSessionId = searchParams.get('session_id');
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const { refetch } = useUser();
|
||||
|
||||
if (isLoading) {
|
||||
useEffect(() => {
|
||||
if (!checkoutSessionId) {
|
||||
router.replace('/dashboard/translate');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
router.replace('/dashboard/translate');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const runSync = async () => {
|
||||
setSyncError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/auth/checkout/sync?session_id=${encodeURIComponent(checkoutSessionId)}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!cancelled) {
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
setSyncError(errData.message || 'Erreur lors de la synchronisation du paiement.');
|
||||
} else {
|
||||
await refetch();
|
||||
router.replace('/dashboard/translate');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setSyncError('Erreur réseau. Veuillez rafraîchir la page.');
|
||||
}
|
||||
};
|
||||
|
||||
runSync();
|
||||
return () => { cancelled = true; };
|
||||
}, [checkoutSessionId, refetch, router]);
|
||||
|
||||
if (syncError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground"></div>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
||||
<p className="text-sm text-destructive">{syncError}</p>
|
||||
<button
|
||||
onClick={() => router.replace('/dashboard/translate')}
|
||||
className="text-xs text-muted-foreground underline"
|
||||
>
|
||||
Continuer vers la traduction
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const firstName = user?.name?.split(' ')[0] || 'User';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 lg:px-8 lg:py-8">
|
||||
{/* Page heading */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
Welcome back, {firstName}!
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Monitor your usage, manage API keys, and configure translation preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/dashboard/translate">
|
||||
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Translate Document</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>
|
||||
Upload and translate Excel, Word, or PowerPoint files
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/dashboard/api-keys">
|
||||
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>
|
||||
Manage your API keys for automation
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{user?.tier === 'pro' && (
|
||||
<Link href="/dashboard/glossaries">
|
||||
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Glossaries</CardTitle>
|
||||
<BookText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>
|
||||
Create custom terminology for translations
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan info */}
|
||||
{user?.tier === 'free' && (
|
||||
<Card className="mt-6 border-primary/50 bg-primary/5">
|
||||
<CardContent className="flex items-center justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Upgrade to Pro</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get unlimited translations, API access, and custom glossaries
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/pricing">
|
||||
<Button variant="premium" size="sm" className="gap-1">
|
||||
View Plans
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Upload, FileSpreadsheet, FileText, Presentation } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { UseFileUploadReturn } from './types';
|
||||
|
||||
interface FileDropZoneProps {
|
||||
@@ -11,40 +12,72 @@ interface FileDropZoneProps {
|
||||
|
||||
export function FileDropZone({ upload }: FileDropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
const handleClick = () => inputRef.current?.click();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('dashboard.translate.dropzone.uploadAria')}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-10 transition-colors cursor-pointer',
|
||||
'relative flex flex-col items-center justify-center gap-6 rounded-2xl border-2 border-dashed',
|
||||
'min-h-[320px] cursor-pointer select-none transition-all duration-200 px-8 py-12',
|
||||
upload.isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-muted/30 hover:border-muted-foreground/30'
|
||||
? 'border-primary bg-primary/8 scale-[1.01]'
|
||||
: 'border-border bg-muted/20 hover:border-primary/50 hover:bg-muted/40'
|
||||
)}
|
||||
onDragOver={upload.handleDragOver}
|
||||
onDragLeave={upload.handleDragLeave}
|
||||
onDrop={upload.handleDrop}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => e.key === 'Enter' || e.key === ' ' ? handleClick() : undefined}
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-secondary">
|
||||
<Upload className="size-5 text-muted-foreground" />
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
'flex size-20 items-center justify-center rounded-2xl transition-colors',
|
||||
upload.isDragOver ? 'bg-primary/15' : 'bg-secondary'
|
||||
)}>
|
||||
<Upload className={cn(
|
||||
'size-9 transition-colors',
|
||||
upload.isDragOver ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Drag & drop your .xlsx, .docx, or .pptx file here
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{t('dashboard.translate.dropzone.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.dropzone.subtitle')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">or click to browse</p>
|
||||
</div>
|
||||
|
||||
{/* Format badges */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileSpreadsheet className="size-3.5 text-green-500" />
|
||||
Excel (.xlsx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileText className="size-3.5 text-blue-500" />
|
||||
Word (.docx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Presentation className="size-3.5 text-orange-500" />
|
||||
PowerPoint (.pptx)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
aria-label="Upload file"
|
||||
aria-label={t('dashboard.translate.dropzone.uploadAria')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { Language } from './types';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
@@ -29,36 +30,36 @@ export function LanguageSelector({
|
||||
onSourceChange,
|
||||
onTargetChange,
|
||||
}: LanguageSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
<span>Failed to load languages: {error}</span>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
{/* Source language */}
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Source Language
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.source')}
|
||||
</label>
|
||||
<Select
|
||||
value={sourceLang}
|
||||
onValueChange={onSourceChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Auto-detect" />
|
||||
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||
<SelectItem value="auto">{t('dashboard.translate.language.autoDetect')}</SelectItem>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
@@ -68,25 +69,25 @@ export function LanguageSelector({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="mt-5 size-4 shrink-0 text-muted-foreground" />
|
||||
{/* Arrow — flips in RTL via rtl: variant */}
|
||||
<div className="mb-2 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<ArrowRight className="size-4 rtl:rotate-180" />
|
||||
</div>
|
||||
|
||||
{/* Target language */}
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Target Language
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.target')}
|
||||
</label>
|
||||
<Select
|
||||
value={targetLang}
|
||||
onValueChange={onTargetChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select language" />
|
||||
<SelectValue placeholder={t('dashboard.translate.language.selectPlaceholder')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, CheckCircle2, Lock } from 'lucide-react';
|
||||
import { Loader2, CheckCircle2, Lock, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { Provider, AvailableProvider } from './types';
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
@@ -19,20 +20,21 @@ export function ProviderSelector({
|
||||
isLoadingProviders,
|
||||
isPro,
|
||||
}: ProviderSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (isLoadingProviders) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>Loading providers…</span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>{t('dashboard.translate.provider.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (availableProviders.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
No providers are configured. Ask your administrator to enable at least one in the
|
||||
admin settings.
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-400">
|
||||
{t('dashboard.translate.provider.noneConfigured')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -49,60 +51,86 @@ export function ProviderSelector({
|
||||
disabled={locked}
|
||||
onClick={() => !locked && onProviderChange(p.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg border px-3 py-2.5 text-left text-sm transition-colors',
|
||||
'flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-start transition-all duration-150',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
? 'border-primary bg-primary/8 ring-1 ring-primary/30'
|
||||
: locked
|
||||
? 'cursor-not-allowed border-border/40 bg-muted/30 text-muted-foreground'
|
||||
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/40'
|
||||
? 'cursor-not-allowed border-border/40 bg-muted/20 opacity-60'
|
||||
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/30 active:scale-[0.99]'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium leading-tight">{p.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.description}</span>
|
||||
{/* Selection indicator */}
|
||||
<div className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||
isSelected ? 'border-primary bg-primary' : 'border-border/60'
|
||||
)}>
|
||||
{isSelected && <div className="size-2 rounded-full bg-primary-foreground" />}
|
||||
</div>
|
||||
|
||||
{/* Label + description */}
|
||||
<div className="flex flex-1 flex-col gap-0.5 min-w-0">
|
||||
<span className={cn(
|
||||
'text-sm font-medium leading-tight',
|
||||
isSelected ? 'text-primary' : locked ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}>
|
||||
{p.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{p.description}
|
||||
</span>
|
||||
{p.mode === 'llm' && p.model && (
|
||||
<span className="mt-0.5 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin">
|
||||
Modèle : {p.model}
|
||||
<span className="mt-0.5 text-[10px] font-mono text-muted-foreground/70">
|
||||
{t('dashboard.translate.provider.modelTitle')} {p.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right badge */}
|
||||
{locked ? (
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground/60" />
|
||||
<Lock className="size-4 shrink-0 text-muted-foreground/50" />
|
||||
) : isSelected ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-primary" />
|
||||
<CheckCircle2 className="size-5 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Translation Provider</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.provider.sectionTitle')}
|
||||
</p>
|
||||
|
||||
{/* Classic providers — available to everyone */}
|
||||
{/* Classic providers */}
|
||||
{classicProviders.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{classicProviders.map((p) => renderCard(p, false))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM providers — Pro only */}
|
||||
{llmProviders.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/50" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
LLM · Context-Aware {!isPro && '· Pro'}
|
||||
<span className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Sparkles className="size-3" />
|
||||
{t('dashboard.translate.provider.llmDivider')}
|
||||
{!isPro && (
|
||||
<span className="ms-1 rounded bg-primary/10 px-1 py-0.5 text-primary">
|
||||
{t('dashboard.translate.provider.llmDividerPro')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/50" />
|
||||
</div>
|
||||
{llmProviders.map((p) => renderCard(p, !isPro))}
|
||||
{!isPro && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a href="/pricing" className="text-primary hover:underline">
|
||||
Upgrade to Pro
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
<a href="/pricing" className="text-primary hover:underline font-medium">
|
||||
{t('dashboard.translate.provider.upgrade')}
|
||||
</a>{' '}
|
||||
to use LLM-powered translation.
|
||||
{t('dashboard.translate.provider.upgradeSuffix')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle, Download, Plus, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CheckCircle2, Download, Plus, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface TranslationCompleteProps {
|
||||
jobId: string;
|
||||
@@ -12,8 +13,6 @@ interface TranslationCompleteProps {
|
||||
onNewTranslation: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export function TranslationComplete({
|
||||
jobId,
|
||||
fileName,
|
||||
@@ -21,133 +20,126 @@ export function TranslationComplete({
|
||||
}: TranslationCompleteProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const { success, error } = useNotification();
|
||||
const { t } = useI18n();
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Download failed';
|
||||
let msg = t('dashboard.translate.complete.toastFailDesc');
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorData.error || errorMessage;
|
||||
} catch {
|
||||
// Response not JSON
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
const body = await response.json();
|
||||
msg = body.message || body.error || msg;
|
||||
} catch { /* not JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
downloadFilename = filenameMatch[1];
|
||||
}
|
||||
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (match?.[1]) downloadFilename = match[1];
|
||||
} else if (fileName) {
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const baseName = fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${baseName}_translated.${ext}`;
|
||||
const base = fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${base}_translated.${ext}`;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
|
||||
}, 1000);
|
||||
|
||||
success({
|
||||
title: 'Download Complete',
|
||||
description: `${downloadFilename} has been downloaded successfully.`,
|
||||
title: t('dashboard.translate.complete.toastOkTitle'),
|
||||
description: t('dashboard.translate.complete.toastOkDesc', { name: downloadFilename }),
|
||||
});
|
||||
} catch (err) {
|
||||
error({
|
||||
title: 'Download Failed',
|
||||
description: err instanceof Error ? err.message : 'Failed to download the translated file.',
|
||||
title: t('dashboard.translate.complete.toastFailTitle'),
|
||||
description: err instanceof Error ? err.message : t('dashboard.translate.complete.toastFailDesc'),
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
setTimeout(() => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="border-success/40 bg-gradient-to-br from-success/10 to-success/5 overflow-hidden">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-success" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">Translation Complete!</h3>
|
||||
<p className="text-sm text-muted-foreground mb-5">
|
||||
{fileName ? `"${fileName}" has been translated successfully.` : 'Your document has been translated successfully.'}
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
{/* Success icon */}
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-green-500/15">
|
||||
<CheckCircle2 className="size-10 text-green-500" />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xl font-bold text-foreground">
|
||||
{t('dashboard.translate.complete.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fileName
|
||||
? t('dashboard.translate.complete.descNamed', { name: fileName })
|
||||
: t('dashboard.translate.complete.descGeneric')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Download Translated File
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onNewTranslation}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Translation
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions — stacked column so text is never cut */}
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-12 w-full gap-2 text-base font-semibold"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('dashboard.translate.complete.downloading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="size-5" />
|
||||
{t('dashboard.translate.complete.download')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 w-full gap-2"
|
||||
onClick={onNewTranslation}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface TranslationProgressProps {
|
||||
progress: number;
|
||||
@@ -14,15 +15,6 @@ interface TranslationProgressProps {
|
||||
isCompleted?: boolean;
|
||||
}
|
||||
|
||||
function formatTimeRemaining(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '';
|
||||
if (seconds < 60) return `${seconds}s remaining`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (remainingSeconds === 0) return `${minutes} min remaining`;
|
||||
return `${minutes}m ${remainingSeconds}s remaining`;
|
||||
}
|
||||
|
||||
export function TranslationProgress({
|
||||
progress,
|
||||
currentStep,
|
||||
@@ -32,8 +24,7 @@ export function TranslationProgress({
|
||||
isUploading = false,
|
||||
isCompleted = false,
|
||||
}: TranslationProgressProps) {
|
||||
// Disable CSS transition on the very first render so that when progress
|
||||
// resets from a previous job's 100% → 0%, there is no visible backward sweep.
|
||||
const { t } = useI18n();
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const prevProgressRef = useRef(progress);
|
||||
|
||||
@@ -41,25 +32,40 @@ export function TranslationProgress({
|
||||
if (progress > 0) {
|
||||
setAnimate(true);
|
||||
} else if (progress === 0) {
|
||||
// Momentarily cut the transition to snap to 0, then re-enable.
|
||||
setAnimate(false);
|
||||
const t = setTimeout(() => setAnimate(true), 50);
|
||||
return () => clearTimeout(t);
|
||||
const tid = setTimeout(() => setAnimate(true), 50);
|
||||
return () => clearTimeout(tid);
|
||||
}
|
||||
prevProgressRef.current = progress;
|
||||
}, [progress]);
|
||||
|
||||
function formatTimeRemaining(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '';
|
||||
if (seconds < 60)
|
||||
return t('dashboard.translate.progress.timeSeconds', { seconds: String(seconds) });
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
if (remaining === 0)
|
||||
return t('dashboard.translate.progress.timeMinutes', { minutes: String(minutes) });
|
||||
return t('dashboard.translate.progress.timeMixed', {
|
||||
minutes: String(minutes),
|
||||
seconds: String(remaining),
|
||||
});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-destructive/10 border border-destructive/30 p-4"
|
||||
<div
|
||||
className="rounded-xl bg-destructive/10 border border-destructive/20 p-5"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">Translation Failed</p>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">
|
||||
{t('dashboard.translate.progress.failedTitle')}
|
||||
</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,40 +74,48 @@ export function TranslationProgress({
|
||||
}
|
||||
|
||||
const timeRemaining = formatTimeRemaining(estimatedRemaining);
|
||||
// Only show "Connection lost" when polling was active and then stopped —
|
||||
// never during the initial upload phase.
|
||||
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{currentStep || 'Processing...'}
|
||||
</span>
|
||||
<span className="text-primary font-medium tabular-nums" aria-live="polite">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
<div className="flex flex-col items-center gap-6 py-4">
|
||||
{/* Animated spinner */}
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
animate={animate}
|
||||
className="h-2"
|
||||
aria-label="Translation progress"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
|
||||
{/* Status text */}
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{currentStep || t('dashboard.translate.progress.processingFallback')}
|
||||
</p>
|
||||
{timeRemaining && (
|
||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="size-3.5" aria-hidden />
|
||||
{timeRemaining}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar + percentage */}
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Progress
|
||||
value={progress}
|
||||
animate={animate}
|
||||
className="h-3 rounded-full"
|
||||
aria-label={t('dashboard.translate.progress.ariaProgress')}
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
<p className="text-end text-sm font-semibold tabular-nums text-primary" aria-live="polite">
|
||||
{Math.round(progress)} %
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showConnectionLost && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600">
|
||||
<WifiOff className="h-3 w-3" aria-hidden="true" />
|
||||
<span>Connection lost. Retrying...</span>
|
||||
</div>
|
||||
)}
|
||||
{timeRemaining && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{timeRemaining}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<WifiOff className="size-3.5" aria-hidden />
|
||||
<span>{t('dashboard.translate.progress.connectionLost')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Languages, ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Presentation,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileDropZone } from './FileDropZone';
|
||||
import { FilePreview } from './FilePreview';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
import { useTranslationConfig } from './useTranslationConfig';
|
||||
import { useTranslationSubmit } from './useTranslationSubmit';
|
||||
@@ -14,187 +23,290 @@ import { ProviderSelector } from './ProviderSelector';
|
||||
import { TranslationProgress } from './TranslationProgress';
|
||||
import { TranslationComplete } from './TranslationComplete';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||
xlsx: FileSpreadsheet,
|
||||
docx: FileText,
|
||||
pptx: Presentation,
|
||||
};
|
||||
const FILE_COLORS: Record<string, string> = {
|
||||
xlsx: 'text-green-500',
|
||||
docx: 'text-blue-500',
|
||||
pptx: 'text-orange-500',
|
||||
};
|
||||
|
||||
function fmt(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/* ── Compact file strip ──────────────────────────────────────────── */
|
||||
function FileStrip({
|
||||
file,
|
||||
onRemove,
|
||||
onReplace,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onReplace: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<FileIcon className={`size-5 shrink-0 ${color}`} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReplace}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{t('dashboard.translate.dropzone.replaceFile')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
onClick={onRemove}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Trust row ───────────────────────────────────────────────────── */
|
||||
function TrustRow() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
{t('dashboard.translate.trust.zeroRetention')}
|
||||
</span>
|
||||
<span className="h-3 w-px bg-border" aria-hidden />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
{t('dashboard.translate.trust.deletedAfter')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Page ────────────────────────────────────────────────────────── */
|
||||
export default function TranslatePage() {
|
||||
const upload = useFileUpload();
|
||||
const config = useTranslationConfig(!!upload.file);
|
||||
const submit = useTranslationSubmit();
|
||||
const { error: showError } = useNotification();
|
||||
const { t } = useI18n();
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (submit.error && submit.error !== lastErrorRef.current) {
|
||||
lastErrorRef.current = submit.error;
|
||||
showError({
|
||||
title: t('dashboard.translate.errorNotificationTitle'),
|
||||
description: submit.error,
|
||||
});
|
||||
}
|
||||
}, [submit.error, showError, t]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!upload.file || !config.isConfigValid) return;
|
||||
await submit.submitTranslation(upload.file, config.getConfig());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (submit.error && submit.error !== lastErrorRef.current) {
|
||||
lastErrorRef.current = submit.error;
|
||||
showError({
|
||||
title: 'Translation Error',
|
||||
description: submit.error,
|
||||
});
|
||||
}
|
||||
}, [submit.error, showError]);
|
||||
|
||||
const handleNewTranslation = () => {
|
||||
submit.reset();
|
||||
upload.removeFile();
|
||||
};
|
||||
|
||||
const isConfiguring = upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
||||
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
||||
const isProcessing =
|
||||
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isCompleted = submit.status === 'completed';
|
||||
const isFailed = submit.status === 'failed';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-6 lg:px-8">
|
||||
<Card className="border-border/70 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Languages className="size-5" />
|
||||
Office Translator
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Upload an Excel, Word, or PowerPoint file to translate
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{upload.file && !isProcessing && !isCompleted && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-success/40 bg-success/5 px-6 py-4">
|
||||
<FilePreview file={upload.file} onRemove={upload.removeFile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!upload.file && !isProcessing && !isCompleted && (
|
||||
<FileDropZone upload={upload} />
|
||||
)}
|
||||
|
||||
{upload.error && !isProcessing && !isCompleted && (
|
||||
<p className="text-sm text-destructive">{upload.error}</p>
|
||||
)}
|
||||
|
||||
{isConfiguring && (
|
||||
<>
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">Configuration</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang}
|
||||
targetLang={config.targetLang}
|
||||
languages={config.languages}
|
||||
isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError}
|
||||
onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
|
||||
<ProviderSelector
|
||||
provider={config.provider}
|
||||
onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders}
|
||||
isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full text-sm font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting}
|
||||
onClick={handleTranslate}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Translate Document
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isProcessing && !isCompleted && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>File: {submit.fileName || upload.file?.name}</span>
|
||||
</div>
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={submit.currentStep || (submit.isSubmitting ? 'Uploading file...' : 'Starting translation...')}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={null}
|
||||
isPolling={submit.isPolling}
|
||||
isUploading={submit.isSubmitting}
|
||||
isCompleted={false}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewTranslation}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCompleted && submit.jobId && (
|
||||
<TranslationComplete
|
||||
jobId={submit.jobId}
|
||||
fileName={submit.fileName}
|
||||
onNewTranslation={handleNewTranslation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFailed && (
|
||||
<>
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={submit.currentStep}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={submit.error}
|
||||
isPolling={false}
|
||||
isCompleted={false}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNewTranslation}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!upload.file && !isProcessing && !isCompleted && !isFailed && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
<span>Zero Data Retention</span>
|
||||
/* ── STATE: No file ─────────────────────────────────────────────── */
|
||||
if (!upload.file && !isProcessing && !isCompleted && !isFailed) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.pageSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-px bg-border" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
<span>Files deleted after 60 min</span>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
<TrustRow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Configure — single column, full width ───────────────── */
|
||||
if (isConfiguring) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
{/* Scrollable content */}
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* File strip */}
|
||||
<FileStrip
|
||||
file={upload.file!}
|
||||
onRemove={upload.removeFile}
|
||||
onReplace={() => replaceInputRef.current?.click()}
|
||||
/>
|
||||
<input
|
||||
ref={replaceInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
/>
|
||||
{upload.error && <p className="text-sm text-destructive">{upload.error}</p>}
|
||||
|
||||
{/* Language selector — full width */}
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang}
|
||||
targetLang={config.targetLang}
|
||||
languages={config.languages}
|
||||
isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError}
|
||||
onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
|
||||
{/* Provider selector — full width */}
|
||||
<ProviderSelector
|
||||
provider={config.provider}
|
||||
onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders}
|
||||
isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sticky bottom bar: button + trust */}
|
||||
<div className="shrink-0 border-t border-border/50 bg-background px-6 py-4 lg:px-8">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-13 w-full gap-2 text-base font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting}
|
||||
onClick={handleTranslate}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('dashboard.translate.actions.uploading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('dashboard.translate.actions.translate')}
|
||||
<ArrowRight className="size-5 rtl:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="mt-3">
|
||||
<TrustRow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Processing ──────────────────────────────────────────── */
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.actions.filePrefix')}{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full max-w-md">
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={
|
||||
submit.currentStep ||
|
||||
(submit.isSubmitting
|
||||
? t('dashboard.translate.steps.uploading')
|
||||
: t('dashboard.translate.steps.starting'))
|
||||
}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={null}
|
||||
isPolling={submit.isPolling}
|
||||
isUploading={submit.isSubmitting}
|
||||
isCompleted={false}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
|
||||
<RotateCcw className="size-4" />
|
||||
{t('dashboard.translate.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Complete ────────────────────────────────────────────── */
|
||||
if (isCompleted && submit.jobId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center overflow-y-auto p-6">
|
||||
<TranslationComplete
|
||||
jobId={submit.jobId}
|
||||
fileName={submit.fileName}
|
||||
onNewTranslation={handleNewTranslation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Failed ──────────────────────────────────────────────── */
|
||||
if (isFailed) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
|
||||
<div className="w-full max-w-md">
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={submit.currentStep}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={submit.error}
|
||||
isPolling={false}
|
||||
isCompleted={false}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
|
||||
<RotateCcw className="size-4" />
|
||||
{t('dashboard.translate.actions.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import type {
|
||||
TranslationConfig,
|
||||
AvailableProvider,
|
||||
} from './types';
|
||||
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
/** Fallback when API fails — Google is always available server-side */
|
||||
const FALLBACK_PROVIDERS: AvailableProvider[] = [
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
TranslationSubmitResponse,
|
||||
TranslationStatusResponse
|
||||
} from './types';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
const POLLING_INTERVAL_MS = 2000;
|
||||
const MAX_POLLING_FAILURES = 3;
|
||||
|
||||
@@ -58,6 +57,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
stopPolling();
|
||||
setIsSubmitting(false);
|
||||
setStatus('failed');
|
||||
setError('Translation job not found');
|
||||
return;
|
||||
@@ -81,6 +81,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
stopPolling();
|
||||
setIsSubmitting(false);
|
||||
if (job.status === 'failed') {
|
||||
setError(job.error_message || 'Translation failed');
|
||||
}
|
||||
@@ -92,6 +93,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
|
||||
if (pollingFailuresRef.current >= MAX_POLLING_FAILURES) {
|
||||
stopPolling();
|
||||
setIsSubmitting(false);
|
||||
setStatus('failed');
|
||||
setError('Lost connection to translation service. Please check your internet connection and try again.');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
export interface PlanLimits {
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
max_file_size_mb: number;
|
||||
features: string[];
|
||||
api_access: boolean;
|
||||
providers: string[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
tier: 'free' | 'pro';
|
||||
avatar_url?: string;
|
||||
// Plan information
|
||||
tier: string; // alias for plan (legacy)
|
||||
plan: string; // 'free' | 'starter' | 'pro' | 'business' | 'enterprise'
|
||||
subscription_status: string; // 'active' | 'canceled' | 'past_due' | 'trialing'
|
||||
subscription_ends_at?: string;
|
||||
cancel_at_period_end?: boolean;
|
||||
// Usage stats (current month)
|
||||
docs_translated_this_month: number;
|
||||
pages_translated_this_month: number;
|
||||
api_calls_this_month: number;
|
||||
extra_credits: number;
|
||||
// Plan limits
|
||||
plan_limits?: PlanLimits;
|
||||
// Account
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -17,3 +17,13 @@ export function getInitials(name: string): string {
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
/** Libellé de plan localisé (clés `dashboard.tier.*` dans les messages). */
|
||||
export function translateTier(
|
||||
t: (key: string) => string,
|
||||
tier: string | undefined
|
||||
): string {
|
||||
const key = `dashboard.tier.${tier ?? 'free'}`;
|
||||
const label = t(key);
|
||||
return label === key ? tier ?? t('dashboard.tier.free') : label;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||
import { NotificationProvider } from "@/components/ui/notification";
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
import { Agentation } from "agentation";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -22,15 +24,18 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.className} bg-background text-foreground antialiased`}>
|
||||
<I18nProvider>
|
||||
<QueryProvider>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
</NotificationProvider>
|
||||
</QueryProvider>
|
||||
</I18nProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
||||
<I18nProvider>
|
||||
<QueryProvider>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
</NotificationProvider>
|
||||
</QueryProvider>
|
||||
</I18nProvider>
|
||||
{process.env.NODE_ENV === "development" && <Agentation />}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Check, X, Zap, Building2, Crown, Sparkles, ArrowRight,
|
||||
Check, X, Zap, Building2, Crown, Sparkles, ArrowRight, ArrowLeft,
|
||||
Star, Shield, Rocket, Users, Headphones, Lock, Globe,
|
||||
Clock, ChevronDown, ChevronUp, Cpu, BarChart3, Infinity,
|
||||
FileText, Layers, Brain, BadgeCheck, Gauge
|
||||
@@ -13,6 +13,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Types
|
||||
@@ -76,8 +78,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
price_monthly: 7.99,
|
||||
price_yearly: 76.70,
|
||||
price_monthly: 9.00,
|
||||
price_yearly: 86.40,
|
||||
docs_per_month: 50,
|
||||
max_pages_per_doc: 50,
|
||||
max_file_size_mb: 10,
|
||||
@@ -100,8 +102,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price_monthly: 19.99,
|
||||
price_yearly: 191.90,
|
||||
price_monthly: 19.00,
|
||||
price_yearly: 182.40,
|
||||
docs_per_month: 200,
|
||||
max_pages_per_doc: 200,
|
||||
max_file_size_mb: 25,
|
||||
@@ -129,8 +131,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
price_monthly: 49.99,
|
||||
price_yearly: 479.90,
|
||||
price_monthly: 49.00,
|
||||
price_yearly: 470.40,
|
||||
docs_per_month: 1000,
|
||||
max_pages_per_doc: 500,
|
||||
max_file_size_mb: 50,
|
||||
@@ -213,6 +215,47 @@ const PLAN_COLORS: Record<string, { gradient: string; border: string; badge: str
|
||||
enterprise: { gradient: "from-amber-600 to-amber-700", border: "border-amber-700/50", badge: "bg-amber-600", button: "bg-amber-600 hover:bg-amber-500" },
|
||||
};
|
||||
|
||||
/** Évite le flash des tarifs statiques (STATIC_PLANS) avant la réponse API au refresh. */
|
||||
function PricingDataSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border border-border/40 bg-card overflow-hidden animate-pulse"
|
||||
>
|
||||
<div className="h-28 bg-muted/60" />
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-5/6" />
|
||||
<div className="h-9 bg-muted rounded-lg mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-20">
|
||||
<div className="h-9 bg-muted rounded w-64 mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-96 max-w-full mx-auto mb-10 animate-pulse" />
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/40">
|
||||
<div className="h-64 bg-muted/30 animate-pulse rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-20">
|
||||
<div className="h-9 bg-muted rounded w-72 mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-full max-w-lg mx-auto mb-8 animate-pulse" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-36 rounded-2xl border border-border/40 bg-muted/20 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Puis-je changer de forfait à tout moment ?",
|
||||
@@ -249,27 +292,53 @@ const FAQS = [
|
||||
───────────────────────────────────────────── */
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const planFromUrl = searchParams.get("plan");
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const [plans, setPlans] = useState<Plan[]>(STATIC_PLANS);
|
||||
const [credits, setCredits] = useState<CreditPackage[]>(STATIC_CREDITS);
|
||||
const [currentPlan, setCurrentPlan] = useState<string | null>(null);
|
||||
const [openFAQ, setOpenFAQ] = useState<number | null>(null);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
||||
const [toastMsg, setToastMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
|
||||
/** Tant que false : on n’affiche pas STATIC_PLANS (évite le flash de faux prix au refresh). */
|
||||
const [pricingLoaded, setPricingLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch live plans
|
||||
fetch("/api/v1/auth/plans")
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
const data = json.data ?? json;
|
||||
if (Array.isArray(data.plans) && data.plans.length) setPlans(data.plans);
|
||||
if (Array.isArray(data.credit_packages)) setCredits(data.credit_packages);
|
||||
// Fetch live plans — backend returns prices set by admin (pas de cache navigateur)
|
||||
const plansUrl = `${API_BASE}/api/v1/auth/plans`;
|
||||
fetch(plansUrl, { cache: "no-store" })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(`HTTP ${r.status}`);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.catch(() => {/* keep static fallback */});
|
||||
.then((json) => {
|
||||
const d = json.data ?? json;
|
||||
const meta = (json.meta ?? d.meta) as { annual_discount_percent?: number } | undefined;
|
||||
if (typeof meta?.annual_discount_percent === "number") {
|
||||
setAnnualDiscountPercent(meta.annual_discount_percent);
|
||||
}
|
||||
if (Array.isArray(d.plans) && d.plans.length) setPlans(d.plans);
|
||||
if (Array.isArray(d.credit_packages)) setCredits(d.credit_packages);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("[pricing] Impossible de charger /api/v1/auth/plans — affichage des tarifs par défaut.", err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setPricingLoaded(true);
|
||||
});
|
||||
|
||||
// Fetch current user plan (if logged in)
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
fetch("/api/v1/auth/me", { headers: { Authorization: `Bearer ${token}` } })
|
||||
setIsLoggedIn(true);
|
||||
fetch(`${API_BASE}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
const user = json.data ?? json;
|
||||
@@ -279,15 +348,27 @@ export default function PricingPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-trigger checkout when ?plan= is in the URL and user is already logged in
|
||||
useEffect(() => {
|
||||
if (!pricingLoaded) return;
|
||||
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
|
||||
const targetPlan = plans.find(p => p.id === planFromUrl);
|
||||
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
|
||||
// Directly initiate checkout — no more redirect to /settings/subscription
|
||||
handleSubscribe(planFromUrl);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planFromUrl, isLoggedIn, plans, currentPlan, pricingLoaded]);
|
||||
|
||||
const displayPrice = (plan: Plan) => {
|
||||
if (plan.price_monthly === -1) return null;
|
||||
if (plan.price_monthly === 0) return 0;
|
||||
return isYearly
|
||||
? (plan.price_yearly / 12).toFixed(2)
|
||||
: plan.price_monthly.toFixed(2);
|
||||
const price = isYearly ? (plan.price_yearly / 12) : plan.price_monthly;
|
||||
return Number.isInteger(price) ? price.toString() : price.toFixed(2);
|
||||
};
|
||||
|
||||
const handleSubscribe = (planId: string) => {
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
router.push(`/auth/login?redirect=/pricing`);
|
||||
@@ -297,35 +378,113 @@ export default function PricingPage() {
|
||||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||||
return;
|
||||
}
|
||||
// Redirect to subscription management
|
||||
router.push(`/settings/subscription?plan=${planId}`);
|
||||
if (planId === currentPlan) return;
|
||||
|
||||
setLoadingPlanId(planId);
|
||||
setToastMsg(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/create-checkout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan: planId,
|
||||
billing_period: isYearly ? "yearly" : "monthly",
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const url = data.data?.url ?? data.url;
|
||||
const backendMessage = data.message ?? data.error ?? data.data?.error;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(backendMessage || "Erreur lors de la création du paiement.");
|
||||
}
|
||||
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
// Demo mode: Stripe not yet configured
|
||||
setToastMsg({
|
||||
type: 'ok',
|
||||
text: `Mode démo — Stripe n'est pas encore configuré. En production, vous seriez redirigé vers le paiement pour activer le forfait ${planId}.`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur réseau. Veuillez réessayer.";
|
||||
setToastMsg({ type: 'err', text: message });
|
||||
} finally {
|
||||
setLoadingPlanId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const savingsPercent = Math.round((1 - (10 / 12)) * 100); // ~17 %
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 text-white">
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* ── Top navigation ── */}
|
||||
<div className="sticky top-0 z-40 border-b border-border/60 bg-background/85 backdrop-blur">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Retour
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={isLoggedIn ? "/dashboard" : "/"}
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors"
|
||||
>
|
||||
{isLoggedIn ? "Dashboard" : "Accueil"}
|
||||
</Link>
|
||||
{isLoggedIn && (
|
||||
<Link
|
||||
href="/settings/subscription"
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-accent/80 hover:bg-accent text-accent-foreground transition-colors"
|
||||
>
|
||||
Mon abonnement
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Toast notification ── */}
|
||||
{toastMsg && (
|
||||
<div className={cn(
|
||||
"fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-start gap-3 px-5 py-4 rounded-2xl shadow-2xl border max-w-lg w-full mx-4 backdrop-blur-sm",
|
||||
toastMsg.type === 'ok'
|
||||
? "bg-success/10 border-success/30 text-success"
|
||||
: "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
)}>
|
||||
<span className="text-lg">{toastMsg.type === 'ok' ? '✓' : '✕'}</span>
|
||||
<p className="text-sm flex-1">{toastMsg.text}</p>
|
||||
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="max-w-7xl mx-auto px-4 pt-20 pb-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-300 text-sm font-medium mb-6">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm font-medium mb-6">
|
||||
<Cpu className="w-4 h-4" />
|
||||
Modèles IA mis à jour — Mars 2026
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-4 bg-gradient-to-r from-white via-violet-200 to-violet-400 bg-clip-text text-transparent">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-4 bg-gradient-to-r from-foreground via-accent/80 to-accent bg-clip-text text-transparent">
|
||||
Un forfait pour chaque besoin
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400 max-w-2xl mx-auto mb-8">
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
||||
Traduisez vos documents Word, Excel et PowerPoint en conservant
|
||||
la mise en page originale. Sans jamais saisir de clé API.
|
||||
</p>
|
||||
|
||||
{/* Monthly / Yearly toggle */}
|
||||
<div className="inline-flex items-center gap-3 bg-gray-800/60 border border-gray-700/50 rounded-full p-1.5">
|
||||
<div className="inline-flex items-center gap-3 bg-muted/60 border border-border/50 rounded-full p-1.5">
|
||||
<button
|
||||
onClick={() => setIsYearly(false)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-sm font-medium transition-all",
|
||||
!isYearly ? "bg-white text-gray-900 shadow" : "text-gray-400 hover:text-white"
|
||||
!isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Mensuel
|
||||
@@ -334,19 +493,23 @@ export default function PricingPage() {
|
||||
onClick={() => setIsYearly(true)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-sm font-medium transition-all flex items-center gap-2",
|
||||
isYearly ? "bg-white text-gray-900 shadow" : "text-gray-400 hover:text-white"
|
||||
isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Annuel
|
||||
<span className="bg-emerald-500 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
−20 %
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
−{annualDiscountPercent} %
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards ── */}
|
||||
{/* ── Plan cards (+ comparaison + crédits) : squelette jusqu’à l’API pour éviter le flash des tarifs statiques */}
|
||||
<div className="max-w-7xl mx-auto px-4 pb-20">
|
||||
{!pricingLoaded ? (
|
||||
<PricingDataSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5">
|
||||
{plans.map((plan) => {
|
||||
const Icon = PLAN_ICONS[plan.id] ?? Sparkles;
|
||||
@@ -359,7 +522,7 @@ export default function PricingPage() {
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-2xl border bg-gray-900/60 backdrop-blur transition-all duration-300",
|
||||
"relative flex flex-col rounded-2xl border bg-card backdrop-blur transition-all duration-300",
|
||||
"hover:scale-[1.02] hover:shadow-2xl",
|
||||
colors.border,
|
||||
plan.popular && "ring-2 ring-violet-500/50 shadow-violet-500/20 shadow-xl"
|
||||
@@ -437,11 +600,11 @@ export default function PricingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700/40 pt-3 space-y-2">
|
||||
<div className="border-t border-border/40 pt-3 space-y-2">
|
||||
{plan.features.map((feat, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-gray-300 text-xs leading-snug">{feat}</span>
|
||||
<Check className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground text-xs leading-snug">{feat}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -452,7 +615,7 @@ export default function PricingPage() {
|
||||
{isCurrent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-emerald-600/50 text-emerald-400 hover:bg-emerald-600/10"
|
||||
className="w-full border-success/50 text-success hover:bg-success/10"
|
||||
onClick={() => router.push("/settings/subscription")}
|
||||
>
|
||||
<BadgeCheck className="w-4 h-4 mr-1" /> Gérer mon forfait
|
||||
@@ -460,7 +623,7 @@ export default function PricingPage() {
|
||||
) : plan.id === "free" && !currentPlan ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-gray-600/50 text-gray-400 hover:bg-gray-700/30"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted/30"
|
||||
onClick={() => router.push("/auth/register")}
|
||||
>
|
||||
Commencer gratuitement
|
||||
@@ -468,13 +631,27 @@ export default function PricingPage() {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
disabled={loadingPlanId !== null}
|
||||
className={cn(
|
||||
"w-full py-2.5 px-4 rounded-xl text-sm font-semibold text-white transition-all flex items-center justify-center gap-2",
|
||||
colors.button
|
||||
colors.button,
|
||||
loadingPlanId !== null && "opacity-70 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isEnterprise ? "Nous contacter" : "Choisir ce forfait"}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
{loadingPlanId === plan.id ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Traitement…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEnterprise ? "Nous contacter" : "Choisir ce forfait"}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -486,15 +663,15 @@ export default function PricingPage() {
|
||||
{/* ── Feature comparison table ── */}
|
||||
<div className="mt-20">
|
||||
<h2 className="text-3xl font-bold text-center mb-2">Comparaison détaillée</h2>
|
||||
<p className="text-gray-400 text-center mb-10">Tout ce qui est inclus dans chaque forfait</p>
|
||||
<p className="text-muted-foreground text-center mb-10">Tout ce qui est inclus dans chaque forfait</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-gray-700/40">
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-800/60 border-b border-gray-700/40">
|
||||
<th className="text-left py-4 px-6 text-gray-300 font-medium">Fonctionnalité</th>
|
||||
<tr className="bg-muted/60 border-b border-border/40">
|
||||
<th className="text-left py-4 px-6 text-muted-foreground font-medium">Fonctionnalité</th>
|
||||
{plans.slice(0, 4).map((p) => (
|
||||
<th key={p.id} className={cn("py-4 px-4 text-center font-medium", p.popular ? "text-violet-300" : "text-gray-300")}>
|
||||
<th key={p.id} className={cn("py-4 px-4 text-center font-medium", p.popular ? "text-accent" : "text-muted-foreground")}>
|
||||
{p.name}
|
||||
</th>
|
||||
))}
|
||||
@@ -513,16 +690,16 @@ export default function PricingPage() {
|
||||
{ label: "Traitement prioritaire", vals: plans.slice(0,4).map(p => p.priority_processing) },
|
||||
{ label: "Support", vals: ["Communauté", "E-mail", "Prioritaire", "Dédié"] },
|
||||
].map((row, i) => (
|
||||
<tr key={i} className={cn("border-b border-gray-700/20", i % 2 === 0 ? "bg-gray-800/20" : "")}>
|
||||
<td className="py-3 px-6 text-gray-300">{row.label}</td>
|
||||
<tr key={i} className={cn("border-b border-border/20", i % 2 === 0 ? "bg-muted/20" : "")}>
|
||||
<td className="py-3 px-6 text-muted-foreground">{row.label}</td>
|
||||
{row.vals.map((val, j) => (
|
||||
<td key={j} className="py-3 px-4 text-center">
|
||||
{typeof val === "boolean" ? (
|
||||
val
|
||||
? <Check className="w-4 h-4 text-emerald-400 mx-auto" />
|
||||
: <X className="w-4 h-4 text-gray-600 mx-auto" />
|
||||
? <Check className="w-4 h-4 text-emerald-500 mx-auto" />
|
||||
: <X className="w-4 h-4 text-muted-foreground/40 mx-auto" />
|
||||
) : (
|
||||
<span className={cn("text-sm", plans.slice(0,4)[j]?.popular ? "text-violet-200 font-medium" : "text-gray-300")}>
|
||||
<span className={cn("text-sm", plans.slice(0,4)[j]?.popular ? "text-accent font-medium" : "text-muted-foreground")}>
|
||||
{val}
|
||||
</span>
|
||||
)}
|
||||
@@ -538,9 +715,9 @@ export default function PricingPage() {
|
||||
{/* ── Credits ── */}
|
||||
<div className="mt-20">
|
||||
<h2 className="text-3xl font-bold text-center mb-2">Crédits supplémentaires</h2>
|
||||
<p className="text-gray-400 text-center mb-8">
|
||||
<p className="text-muted-foreground text-center mb-8">
|
||||
Besoin de plus ? Achetez des crédits à l'unité, sans abonnement.
|
||||
<span className="text-gray-500"> 1 crédit = 1 page traduite.</span>
|
||||
<span className="text-muted-foreground/70"> 1 crédit = 1 page traduite.</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
|
||||
{credits.map((pkg, i) => (
|
||||
@@ -549,80 +726,82 @@ export default function PricingPage() {
|
||||
className={cn(
|
||||
"relative p-5 rounded-2xl border text-center transition-all hover:scale-105",
|
||||
pkg.popular
|
||||
? "border-violet-500/50 bg-violet-500/10 shadow-violet-500/20 shadow-lg"
|
||||
: "border-gray-700/40 bg-gray-800/40"
|
||||
? "border-accent/50 bg-accent/10 shadow-accent/20 shadow-lg"
|
||||
: "border-border/40 bg-card"
|
||||
)}
|
||||
>
|
||||
{pkg.popular && (
|
||||
<div className="absolute -top-2.5 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-violet-600 text-white text-xs rounded-full font-bold">
|
||||
<div className="absolute -top-2.5 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-accent text-accent-foreground text-xs rounded-full font-bold">
|
||||
Le meilleur rapport
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-white">{pkg.credits}</div>
|
||||
<div className="text-gray-400 text-xs mb-3">crédits</div>
|
||||
<div className="text-xl font-bold text-white">{pkg.price} €</div>
|
||||
<div className="text-gray-500 text-xs">{(pkg.price_per_credit * 100).toFixed(0)} cts / crédit</div>
|
||||
<button className="mt-3 w-full py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-xs transition-all">
|
||||
<div className="text-2xl font-bold text-foreground">{pkg.credits}</div>
|
||||
<div className="text-muted-foreground text-xs mb-3">crédits</div>
|
||||
<div className="text-xl font-bold text-foreground">{pkg.price} €</div>
|
||||
<div className="text-muted-foreground text-xs">{(pkg.price_per_credit * 100).toFixed(0)} cts / crédit</div>
|
||||
<button className="mt-3 w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all">
|
||||
Acheter
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Trust signals ── */}
|
||||
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ icon: <Shield className="w-6 h-6 text-emerald-400" />, title: "Chiffrement de bout en bout", sub: "TLS 1.3 + AES-256 au repos" },
|
||||
{ icon: <Globe className="w-6 h-6 text-blue-400" />, title: "130+ langues", sub: "Dont arabe, persan, hébreu (RTL)" },
|
||||
{ icon: <Gauge className="w-6 h-6 text-violet-400" />, title: "Traitement parallèle", sub: "IA multi-thread ultra-rapide" },
|
||||
{ icon: <Clock className="w-6 h-6 text-amber-400" />, title: "Disponible 24/7", sub: "Uptime garanti 99,9 %" },
|
||||
{ icon: <Shield className="w-6 h-6 text-emerald-500" />, title: "Chiffrement de bout en bout", sub: "TLS 1.3 + AES-256 au repos" },
|
||||
{ icon: <Globe className="w-6 h-6 text-blue-500" />, title: "130+ langues", sub: "Dont arabe, persan, hébreu (RTL)" },
|
||||
{ icon: <Gauge className="w-6 h-6 text-accent" />, title: "Traitement parallèle", sub: "IA multi-thread ultra-rapide" },
|
||||
{ icon: <Clock className="w-6 h-6 text-amber-500" />, title: "Disponible 24/7", sub: "Uptime garanti 99,9 %" },
|
||||
].map((t, i) => (
|
||||
<div key={i} className="flex flex-col items-center text-center p-6 rounded-2xl bg-gray-800/30 border border-gray-700/30">
|
||||
<div className="p-3 rounded-full bg-gray-700/50 mb-3">{t.icon}</div>
|
||||
<div className="font-semibold text-white text-sm mb-1">{t.title}</div>
|
||||
<div className="text-gray-500 text-xs">{t.sub}</div>
|
||||
<div key={i} className="flex flex-col items-center text-center p-6 rounded-2xl bg-card border border-border/30">
|
||||
<div className="p-3 rounded-full bg-muted/50 mb-3">{t.icon}</div>
|
||||
<div className="font-semibold text-foreground text-sm mb-1">{t.title}</div>
|
||||
<div className="text-muted-foreground text-xs">{t.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── AI Models info ── */}
|
||||
<div className="mt-20 p-8 rounded-2xl bg-gradient-to-br from-violet-900/20 to-gray-800/40 border border-violet-500/20">
|
||||
<div className="mt-20 p-8 rounded-2xl bg-gradient-to-br from-accent/10 to-card border border-accent/20">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Brain className="w-6 h-6 text-violet-400" />
|
||||
<Brain className="w-6 h-6 text-accent" />
|
||||
<h2 className="text-2xl font-bold">Nos modèles IA — Mars 2026</h2>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="p-5 rounded-xl bg-gray-800/50 border border-gray-700/40">
|
||||
<div className="p-5 rounded-xl bg-card border border-border/40">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-blue-400" />
|
||||
<Zap className="w-4 h-4 text-blue-500" />
|
||||
<span className="font-semibold">Traduction IA Essentielle</span>
|
||||
<Badge className="ml-auto text-xs bg-blue-600/20 text-blue-300 border-blue-600/30">Forfait Pro</Badge>
|
||||
<Badge className="ml-auto text-xs bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">Forfait Pro</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mb-3">
|
||||
Basée sur <strong className="text-white">DeepSeek V3.2</strong> — le modèle IA le plus rentable de 2026.
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
Basée sur <strong className="text-foreground">DeepSeek V3.2</strong> — le modèle IA le plus rentable de 2026.
|
||||
Qualité comparable aux modèles frontier à 1/50ème du coût.
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-gray-700/50 rounded">163K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-gray-700/50 rounded">$0.25/$0.38 per 1M</span>
|
||||
<span className="px-2 py-1 bg-emerald-600/20 text-emerald-300 rounded">Excellent rapport qualité/prix</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">163K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">$0.25/$0.38 per 1M</span>
|
||||
<span className="px-2 py-1 bg-success/10 text-success rounded">Excellent rapport qualité/prix</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-xl bg-gray-800/50 border border-violet-700/40">
|
||||
<div className="p-5 rounded-xl bg-card border border-accent/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Crown className="w-4 h-4 text-violet-400" />
|
||||
<Crown className="w-4 h-4 text-accent" />
|
||||
<span className="font-semibold">Traduction IA Premium</span>
|
||||
<Badge className="ml-auto text-xs bg-violet-600/20 text-violet-300 border-violet-600/30">Forfait Business</Badge>
|
||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">Forfait Business</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mb-3">
|
||||
Basée sur <strong className="text-white">Claude 3.5 Haiku</strong> d'Anthropic — précis sur les documents
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
Basée sur <strong className="text-foreground">Claude 3.5 Haiku</strong> d'Anthropic — précis sur les documents
|
||||
juridiques, médicaux et techniques complexes.
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-gray-700/50 rounded">200K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-gray-700/50 rounded">$0.25/$1.25 per 1M</span>
|
||||
<span className="px-2 py-1 bg-violet-600/20 text-violet-300 rounded">Meilleure précision</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">200K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">$0.25/$1.25 per 1M</span>
|
||||
<span className="px-2 py-1 bg-accent/10 text-accent rounded">Meilleure précision</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,20 +814,20 @@ export default function PricingPage() {
|
||||
{FAQS.map((faq, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-gray-700/40 bg-gray-800/30 overflow-hidden"
|
||||
className="rounded-xl border border-border/40 bg-card overflow-hidden"
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-5 text-left hover:bg-gray-700/20 transition-colors"
|
||||
className="w-full flex items-center justify-between p-5 text-left hover:bg-muted/20 transition-colors"
|
||||
onClick={() => setOpenFAQ(openFAQ === i ? null : i)}
|
||||
>
|
||||
<span className="font-medium text-gray-200">{faq.q}</span>
|
||||
<span className="font-medium text-foreground">{faq.q}</span>
|
||||
{openFAQ === i
|
||||
? <ChevronUp className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
: <ChevronDown className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
? <ChevronUp className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
: <ChevronDown className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
}
|
||||
</button>
|
||||
{openFAQ === i && (
|
||||
<div className="px-5 pb-5 text-gray-400 text-sm leading-relaxed border-t border-gray-700/30 pt-4">
|
||||
<div className="px-5 pb-5 text-muted-foreground text-sm leading-relaxed border-t border-border/30 pt-4">
|
||||
{faq.a}
|
||||
</div>
|
||||
)}
|
||||
@@ -658,20 +837,20 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
{/* ── CTA bottom ── */}
|
||||
<div className="mt-20 text-center p-12 rounded-2xl bg-gradient-to-br from-violet-900/30 to-gray-800/40 border border-violet-500/20">
|
||||
<div className="mt-20 text-center p-12 rounded-2xl bg-gradient-to-br from-accent/10 to-card border border-accent/20">
|
||||
<h2 className="text-3xl font-bold mb-3">Prêt à commencer ?</h2>
|
||||
<p className="text-gray-400 mb-8 max-w-lg mx-auto">
|
||||
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
|
||||
Commencez gratuitement, sans carte bancaire. Passez à un forfait supérieur quand vous en avez besoin.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/auth/register">
|
||||
<Button className="bg-violet-600 hover:bg-violet-500 text-white px-8 py-3 text-base font-semibold">
|
||||
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground px-8 py-3 text-base font-semibold">
|
||||
Créer un compte gratuit
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/auth/login">
|
||||
<Button variant="outline" className="border-gray-600 text-gray-300 hover:bg-gray-700/30 px-8 py-3 text-base">
|
||||
<Button variant="outline" className="px-8 py-3 text-base">
|
||||
Se connecter
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -687,11 +866,11 @@ function Stat({ icon, label, value, highlight }: { icon: React.ReactNode; label:
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs",
|
||||
highlight ? "bg-violet-500/10 border border-violet-500/20" : "bg-gray-800/40 border border-gray-700/30"
|
||||
highlight ? "bg-accent/10 border border-accent/20" : "bg-muted/40 border border-border/30"
|
||||
)}>
|
||||
<span className={highlight ? "text-violet-400" : "text-gray-500"}>{icon}</span>
|
||||
<span className="text-gray-400">{label} :</span>
|
||||
<span className={cn("font-medium ml-auto", highlight ? "text-violet-300" : "text-gray-200")}>{value}</span>
|
||||
<span className={highlight ? "text-accent" : "text-muted-foreground"}>{icon}</span>
|
||||
<span className="text-muted-foreground">{label} :</span>
|
||||
<span className={cn("font-medium ml-auto", highlight ? "text-accent" : "text-foreground")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user