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:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -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&#10;récupérateur=heat recovery unit&#10;ventilo-connecteur=fan coil unit&#10;gaine=duct&#10;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 ? (
<>

View File

@@ -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 },

View File

@@ -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>

View File

@@ -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 },
];

View File

@@ -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 nest 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 (

View 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 lURL 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 à lenregistrement 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>
);
}

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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 {

View 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 };
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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[] {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = '';

View File

@@ -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) && (

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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[] = [

View File

@@ -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.');
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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 naffiche 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à lAPI 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>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useTranslation } from "@/lib/i18n";
import { FileText } from "lucide-react";
/** Un seul exemple : même .docx, original EN à gauche, traduction FR à droite. */
const SOURCE_DOC = {
heading: "1. Scope and objectives",
content: (
<>
<p className="mb-2 leading-relaxed">
This document defines the <strong className="text-emerald-700 bg-emerald-50 px-1 rounded font-semibold border border-emerald-200">thermal performance requirements</strong> for the HVAC subsystem. All contractors must submit compliance certificates before commissioning.
</p>
<div className="my-3 border border-slate-200 rounded p-1 bg-white shadow-sm">
<img src="/revenue_chart.png" alt="Corporate Chart" className="w-full h-auto rounded" />
</div>
<p className="mb-2 leading-relaxed">
The design shall maintain indoor comfort per <span className="underline decoration-blue-400 decoration-2 font-medium">ISO 7730</span>. Deviations require written approval from the project engineer.
</p>
</>
),
};
const TARGET_DOC = {
heading: "1. Périmètre et objectifs",
content: (
<>
<p className="mb-2 leading-relaxed">
Le présent document définit les <strong className="text-emerald-700 bg-emerald-50 px-1 rounded font-semibold border border-emerald-200">exigences de performance thermique</strong> pour le sous-système CVC. Toutes les entreprises doivent fournir des certificats de conformité avant la mise en service.
</p>
<div className="my-3 border border-slate-200 rounded p-1 bg-white shadow-sm">
<img src="/revenue_chart.png" alt="Corporate Chart" className="w-full h-auto rounded" />
</div>
<p className="mb-2 leading-relaxed">
La conception doit garantir le confort intérieur conformément à <span className="underline decoration-blue-400 decoration-2 font-medium">l'ISO 7730</span>. Toute dérogation nécessite l'accord écrit de l'ingénieur de projet.
</p>
</>
),
};
export function HeroWordComparison() {
const { t } = useTranslation();
return (
<div className="relative w-full max-w-xl mx-auto lg:max-w-none lg:mx-0">
<div className="relative rounded-xl bg-[#1a1a1c] border border-[#27272A] shadow-2xl overflow-hidden ring-1 ring-white/5">
<div className="absolute -inset-px bg-gradient-to-br from-primary/20 via-transparent to-emerald-500/10 opacity-40 blur-xl pointer-events-none rounded-xl" />
<div className="relative flex items-center gap-2 px-3 py-2 bg-[#252526] border-b border-[#3c3c3c]">
<div className="flex gap-1.5" aria-hidden>
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f57]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#febc2e]" />
<span className="h-2.5 w-2.5 rounded-full bg-[#28c840]" />
</div>
<div className="flex flex-1 items-center justify-center gap-2 min-w-0">
<FileText className="h-4 w-4 shrink-0 text-[#2b579a]" aria-hidden />
<span className="truncate text-xs text-slate-300 font-medium">
{t("landing.heroDoc.fileName")}
</span>
<span className="hidden sm:inline text-[10px] text-slate-500 uppercase tracking-wide shrink-0">
Word
</span>
</div>
</div>
<div className="relative flex items-center gap-3 px-3 py-1.5 bg-[#2d2d30] border-b border-[#3c3c3c] text-[10px] text-slate-400">
<span className="text-white font-medium">{t("landing.heroDoc.ribbonFile")}</span>
<span>{t("landing.heroDoc.ribbonHome")}</span>
<span>{t("landing.heroDoc.ribbonInsert")}</span>
<span>{t("landing.heroDoc.ribbonLayout")}</span>
<span className="ml-auto hidden sm:flex items-center gap-1 text-slate-500" aria-hidden>
<span className="px-1 rounded bg-[#3c3c3c] font-semibold text-slate-300">B</span>
<span className="px-1 rounded bg-[#3c3c3c] italic text-slate-300">I</span>
<span className="px-1 rounded bg-[#3c3c3c] underline text-slate-300">U</span>
</span>
</div>
<div className="relative p-2 sm:p-4 bg-[#1e1e1e]">
<p className="mb-3 text-center text-xs text-slate-400 sm:text-left sm:px-1">
{t("landing.heroDoc.caption")}
</p>
<div className="flex rounded-lg overflow-hidden border border-[#3c3c3c] shadow-inner min-h-[240px] sm:min-h-[300px]">
<div className="w-1/2 border-r border-[#c8c8c8] bg-[#f3f3f3] flex flex-col">
<div className="shrink-0 px-2 py-1.5 bg-[#e7e7e7] border-b border-[#d0d0d0]">
<span className="text-[10px] font-semibold uppercase tracking-wide text-[#444] truncate block">
{t("landing.heroDoc.badgeOriginal")}
</span>
</div>
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-left">
<article
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
style={{
fontFamily:
'Calibri, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
}}
>
<h3 className="text-sm sm:text-base font-bold text-[#2b579a] mb-2">
{SOURCE_DOC.heading}
</h3>
{SOURCE_DOC.content}
</article>
</div>
</div>
<div className="w-1/2 bg-[#f0f7ff] flex flex-col">
<div className="shrink-0 px-2 py-1.5 bg-[#ddeaf7] border-b border-[#b4c7e7]">
<span className="text-[10px] font-semibold uppercase tracking-wide text-[#1a3a5c] truncate block">
{t("landing.heroDoc.badgeTranslated")}
</span>
</div>
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-left">
<article
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
style={{
fontFamily:
'Calibri, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
}}
>
<h3 className="text-sm sm:text-base font-bold text-[#2b579a] mb-2">
{TARGET_DOC.heading}
</h3>
{TARGET_DOC.content}
</article>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,13 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useTranslation } from "@/lib/i18n";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { LanguageSwitcher } from "@/components/ui/language-switcher";
import { ThemeToggle } from "@/components/ui/theme-toggle";
import { HeroWordComparison } from "@/components/landing/hero-word-comparison";
import {
Table2,
FileText,
@@ -20,53 +24,55 @@ import {
export function LandingPage() {
const { t } = useTranslation();
const [isYearly, setIsYearly] = useState(false);
return (
<div className="min-h-screen bg-[#0A0A0B] text-white font-sans">
<div className="min-h-screen bg-background text-foreground font-sans">
{/* Header */}
<header className="sticky top-0 z-50 w-full border-b border-[#27272A] bg-[#0A0A0B]/80 backdrop-blur-md">
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center rounded-lg bg-primary/10 p-1.5">
<Languages className="h-5 w-5 text-primary" />
</div>
<span className="text-white text-lg font-bold tracking-tight">
<span className="text-foreground text-lg font-bold tracking-tight">
Office Translator
</span>
</div>
<nav className="hidden md:flex items-center gap-8">
<a
className="text-slate-300 hover:text-white text-sm font-medium transition-colors"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
href="#features"
>
{t("nav.features")}
</a>
<a
className="text-slate-300 hover:text-white text-sm font-medium transition-colors"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
href="#pricing"
>
{t("nav.pricing")}
</a>
<a
className="text-slate-300 hover:text-white text-sm font-medium transition-colors"
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
href="#enterprise"
>
{t("nav.enterprise")}
</a>
</nav>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<LanguageSwitcher variant="button" />
<ThemeToggle />
<a
className="hidden sm:inline-flex h-9 items-center justify-center rounded-lg px-4 text-sm font-medium text-slate-300 transition-colors hover:text-white"
className="hidden sm:inline-flex h-9 items-center justify-center rounded-lg px-4 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
href="/auth/login"
>
{t("common.login")}
</a>
<Button
asChild
className="bg-primary text-[#0A0A0B] font-bold hover:bg-primary/90"
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
>
<Link href="/auth/register">{t("common.signup")}</Link>
</Button>
@@ -77,13 +83,13 @@ export function LandingPage() {
<main>
{/* Hero Section */}
<section className="relative pt-20 pb-32 overflow-hidden">
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-[#0A0A0B] to-[#0A0A0B]" />
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background" />
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
{/* Hero Text */}
<div className="flex-1 text-center lg:text-left space-y-8">
<h1 className="text-4xl font-extrabold tracking-tight text-white sm:text-5xl lg:text-6xl xl:text-7xl">
<h1 className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-6xl xl:text-7xl">
{t("hero.title")}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
@@ -91,7 +97,7 @@ export function LandingPage() {
</span>
</h1>
<p className="mx-auto lg:mx-0 max-w-2xl text-lg text-slate-400">
<p className="mx-auto lg:mx-0 max-w-2xl text-lg text-muted-foreground">
{t("hero.subtitle")}
</p>
@@ -99,14 +105,14 @@ export function LandingPage() {
<Button
asChild
size="lg"
className="bg-primary text-[#0A0A0B] font-bold hover:bg-primary/90"
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
>
<Link href="/auth/register">{t("hero.cta")}</Link>
</Button>
<Button
variant="outline"
size="lg"
className="border-[#27272A] bg-[#141416] text-white hover:bg-[#27272A] gap-2"
className="gap-2"
>
<PlayCircle className="h-5 w-5" />
{t("hero.demoCta")}
@@ -115,251 +121,240 @@ export function LandingPage() {
{/* Trust Badges */}
<div className="pt-8 flex flex-wrap justify-center lg:justify-start gap-6">
<div className="flex items-center gap-2 text-sm text-slate-400">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ShieldCheck className="h-5 w-5 text-primary" />
<span>{t("hero.badge1")}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-400">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-5 w-5 text-primary" />
<span>{t("hero.badge2")}</span>
</div>
</div>
</div>
{/* Hero Visual */}
{/* Hero Visual — Word EN | FR + illustrations */}
<div className="flex-1 w-full max-w-lg lg:max-w-none relative">
<div className="relative rounded-xl bg-[#141416] border border-[#27272A] p-2 shadow-2xl">
<div className="absolute -inset-0.5 bg-gradient-to-br from-primary/30 to-transparent opacity-30 blur-lg rounded-xl" />
<div className="relative aspect-[16/10] overflow-hidden rounded-lg bg-[#0A0A0B]">
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-full h-full flex">
<div className="w-1/2 border-r border-[#27272A] relative p-4">
<div className="absolute top-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{t("landing.originalLabel")} (EN)
</div>
<div className="mt-8 space-y-2">
<div className="h-3 bg-slate-700 rounded w-3/4" />
<div className="h-3 bg-slate-700 rounded w-1/2" />
<div className="h-3 bg-slate-700 rounded w-5/6" />
</div>
</div>
<div className="w-1/2 relative p-4">
<div className="absolute top-2 left-2 bg-primary text-[#0A0A0B] font-bold text-xs px-2 py-1 rounded">
{t("landing.translatedLabel")} (FR)
</div>
<div className="mt-8 space-y-2">
<div className="h-3 bg-primary/30 rounded w-3/4" />
<div className="h-3 bg-primary/30 rounded w-1/2" />
<div className="h-3 bg-primary/30 rounded w-5/6" />
</div>
</div>
</div>
</div>
</div>
</div>
<HeroWordComparison />
</div>
</div>
</div>
</section>
{/* Features Grid */}
<section className="py-24 bg-[#0A0A0B] relative" id="features">
<section className="py-24 bg-background relative" id="features">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl mb-4">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl mb-4">
{t("features.title")}
</h2>
<p className="text-lg text-slate-400 max-w-2xl mx-auto">
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{t("features.subtitle")}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="flex flex-wrap justify-center gap-6">
{/* Feature 1 - Excel */}
<div className="group relative rounded-2xl border border-[#27272A] bg-[#141416] p-8 hover:border-primary/50 transition-colors">
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Table2 className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-white mb-2">
<h3 className="text-xl font-bold text-foreground mb-2">
{t("features.excel.title")}
</h3>
<p className="text-slate-400">{t("features.excel.description")}</p>
<p className="text-muted-foreground">{t("features.excel.description")}</p>
</div>
{/* Feature 2 - Word */}
<div className="group relative rounded-2xl border border-[#27272A] bg-[#141416] p-8 hover:border-primary/50 transition-colors">
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileText className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-white mb-2">
<h3 className="text-xl font-bold text-foreground mb-2">
{t("features.word.title")}
</h3>
<p className="text-slate-400">{t("features.word.description")}</p>
<p className="text-muted-foreground">{t("features.word.description")}</p>
</div>
{/* Feature 3 - PowerPoint */}
<div className="group relative rounded-2xl border border-[#27272A] bg-[#141416] p-8 hover:border-primary/50 transition-colors">
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Presentation className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-white mb-2">
<h3 className="text-xl font-bold text-foreground mb-2">
{t("features.powerpoint.title")}
</h3>
<p className="text-slate-400">
<p className="text-muted-foreground">
{t("features.powerpoint.description")}
</p>
</div>
{/* Feature 4 - AI */}
<div className="group relative rounded-2xl border border-[#27272A] bg-[#141416] p-8 hover:border-primary/50 transition-colors">
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Bot className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-white mb-2">
<h3 className="text-xl font-bold text-foreground mb-2">
{t("features.ai.title")}
</h3>
<p className="text-slate-400">{t("features.ai.description")}</p>
<p className="text-muted-foreground">{t("features.ai.description")}</p>
</div>
{/* Feature 5 - Privacy */}
<div className="group relative rounded-2xl border border-[#27272A] bg-[#141416] p-8 hover:border-primary/50 transition-colors">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-white mb-2">
{t("features.privacy.title")}
</h3>
<p className="text-slate-400">{t("features.privacy.description")}</p>
</div>
{/* Feature 6 - Speed */}
<div className="group relative rounded-2xl border border-[#27272A] bg-[#141416] p-8 hover:border-primary/50 transition-colors">
{/* Feature 5 - Speed */}
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Zap className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-white mb-2">
<h3 className="text-xl font-bold text-foreground mb-2">
{t("features.speed.title")}
</h3>
<p className="text-slate-400">{t("features.speed.description")}</p>
<p className="text-muted-foreground">{t("features.speed.description")}</p>
</div>
</div>
</div>
</section>
{/* Pricing Section */}
<section className="py-24 bg-[#050505]" id="pricing">
<section className="py-24 bg-muted/30" id="pricing">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl mb-4">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl mb-4">
{t("pricing.title")}
</h2>
<p className="text-lg text-slate-400">{t("pricing.subtitle")}</p>
<p className="text-lg text-muted-foreground">{t("pricing.subtitle")}</p>
<div className="flex justify-center mt-8">
<div className="inline-flex items-center gap-3 bg-card border border-border 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-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
)}
>
{t("common.monthly")}
</button>
<button
onClick={() => setIsYearly(true)}
className={cn(
"px-5 py-2 rounded-full text-sm font-medium transition-all flex items-center gap-2",
isYearly ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
)}
>
{t("common.yearly")}
<span className={cn("text-xs px-1.5 py-0.5 rounded-full font-bold", isYearly ? "bg-background text-primary" : "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400")}>
{t("common.save20")}
</span>
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{/* Free Plan */}
<div className="rounded-2xl border border-[#27272A] bg-[#141416] p-8 flex flex-col">
<h3 className="text-xl font-semibold text-white">
{t("pricing.free.name")}
{/* Starter Plan */}
<div className="rounded-2xl border border-border bg-card p-8 flex flex-col">
<h3 className="text-xl font-semibold text-foreground">
{t("pricing.starter.name")}
</h3>
<p className="mt-4 text-slate-400 text-sm">
{t("pricing.free.description")}
<p className="mt-4 text-muted-foreground text-sm">
{t("pricing.starter.description")}
</p>
<div className="my-6">
<span className="text-4xl font-bold text-white">
{t("pricing.free.price")}
<div className="mt-6 mb-2">
<span className="text-4xl font-bold text-foreground">
{isYearly ? t("pricing.starter.priceYearly") : t("pricing.starter.priceMonthly")}
</span>
<span className="text-slate-500">/{t("common.month")}</span>
<span className="text-muted-foreground">/{t("common.month")}</span>
</div>
<div className="h-6 mb-4">
{isYearly && (
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("pricing.starter.billedYearly")}
</span>
)}
</div>
<ul className="space-y-4 mb-8 flex-1">
{["5 documents / month", "Word & Excel only", "Community support"].map(
(feature, i) => (
<li key={i} className="flex items-center text-slate-300 text-sm">
<Check className="h-4 w-4 text-primary mr-2" />
{feature}
</li>
)
)}
{[0, 1, 2, 3].map((i) => (
<li key={i} className="flex items-center text-muted-foreground text-sm">
<Check className="h-4 w-4 text-primary mr-2 shrink-0" />
{t(`pricing.starter.features.${i}`)}
</li>
))}
</ul>
<Button
asChild
variant="outline"
className="border-[#27272A] text-white hover:bg-[#27272A]"
>
<Link href="/auth/register">{t("common.signup")}</Link>
<Button asChild variant="outline">
<Link href="/pricing?plan=starter">{t("common.signup")}</Link>
</Button>
</div>
{/* Pro Plan */}
<div className="relative rounded-2xl border border-primary bg-[#141416] p-8 flex flex-col shadow-lg shadow-primary/10 scale-105 z-10">
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-[#F59E0B] px-3 py-1 text-xs font-bold text-black uppercase tracking-wide">
<div className="relative rounded-2xl border border-primary bg-card p-8 flex flex-col shadow-lg shadow-primary/10 scale-105 z-10">
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-warning px-3 py-1 text-xs font-bold text-warning-foreground uppercase tracking-wide">
{t("common.popular")}
</div>
<h3 className="text-xl font-semibold text-white">
<h3 className="text-xl font-semibold text-foreground">
{t("pricing.pro.name")}
</h3>
<p className="mt-4 text-slate-400 text-sm">
<p className="mt-4 text-muted-foreground text-sm">
{t("pricing.pro.description")}
</p>
<div className="my-6">
<span className="text-4xl font-bold text-white">
{t("pricing.pro.price")}
<div className="mt-6 mb-2">
<span className="text-4xl font-bold text-foreground">
{isYearly ? t("pricing.pro.priceYearly") : t("pricing.pro.priceMonthly")}
</span>
<span className="text-slate-500">/{t("common.month")}</span>
<span className="text-muted-foreground">/{t("common.month")}</span>
</div>
<div className="h-6 mb-4">
{isYearly && (
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("pricing.pro.billedYearly")}
</span>
)}
</div>
<ul className="space-y-4 mb-8 flex-1">
{[
"50 documents / month",
"All formats (PPTX included)",
"Advanced AI models (GPT-4)",
"Priority support",
].map((feature, i) => (
<li key={i} className="flex items-center text-white text-sm">
<Check className="h-4 w-4 text-primary mr-2" />
{feature}
{[0, 1, 2, 3].map((i) => (
<li key={i} className="flex items-center text-foreground text-sm">
<Check className="h-4 w-4 text-primary mr-2 shrink-0" />
{t(`pricing.pro.features.${i}`)}
</li>
))}
</ul>
<Button
asChild
className="bg-primary text-[#0A0A0B] font-bold hover:bg-primary/90"
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
>
<Link href="/auth/register">{t("common.tryPro")}</Link>
<Link href="/pricing?plan=pro">{t("common.tryPro")}</Link>
</Button>
</div>
{/* Enterprise Plan */}
<div className="rounded-2xl border border-[#27272A] bg-[#141416] p-8 flex flex-col">
<h3 className="text-xl font-semibold text-white">
{t("pricing.enterprise.name")}
{/* Business Plan */}
<div className="rounded-2xl border border-border bg-card p-8 flex flex-col">
<h3 className="text-xl font-semibold text-foreground">
{t("pricing.business.name")}
</h3>
<p className="mt-4 text-slate-400 text-sm">
{t("pricing.enterprise.description")}
<p className="mt-4 text-muted-foreground text-sm">
{t("pricing.business.description")}
</p>
<div className="my-6">
<span className="text-4xl font-bold text-white">
{t("common.onRequest")}
<div className="mt-6 mb-2">
<span className="text-4xl font-bold text-foreground">
{isYearly ? t("pricing.business.priceYearly") : t("pricing.business.priceMonthly")}
</span>
<span className="text-muted-foreground">/{t("common.month")}</span>
</div>
<div className="h-6 mb-4">
{isYearly && (
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("pricing.business.billedYearly")}
</span>
)}
</div>
<ul className="space-y-4 mb-8 flex-1">
{[
"Unlimited documents",
"API Access",
"SSO & Advanced Security",
"Dedicated Account Manager",
].map((feature, i) => (
<li key={i} className="flex items-center text-slate-300 text-sm">
<Check className="h-4 w-4 text-primary mr-2" />
{feature}
{[0, 1, 2, 3].map((i) => (
<li key={i} className="flex items-center text-muted-foreground text-sm">
<Check className="h-4 w-4 text-primary mr-2 shrink-0" />
{t(`pricing.business.features.${i}`)}
</li>
))}
</ul>
<Button
asChild
variant="outline"
className="border-[#27272A] text-white hover:bg-[#27272A]"
>
<Link href="#contact">{t("common.contactSales")}</Link>
<Button asChild variant="outline">
<Link href="/pricing?plan=business">{t("common.tryPro")}</Link>
</Button>
</div>
</div>
@@ -367,20 +362,20 @@ export function LandingPage() {
</section>
{/* CTA Section */}
<section className="relative py-20 px-4">
<section className="relative py-20 px-4 bg-background">
<div className="absolute inset-0 bg-gradient-to-t from-primary/5 to-transparent pointer-events-none" />
<div className="mx-auto max-w-4xl rounded-3xl bg-[#141416] border border-[#27272A] p-12 text-center shadow-2xl overflow-hidden relative">
<div className="mx-auto max-w-4xl rounded-3xl bg-card border border-border p-12 text-center shadow-2xl overflow-hidden relative">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary to-transparent opacity-50" />
<h2 className="text-3xl font-bold text-white mb-6">
<h2 className="text-3xl font-bold text-foreground mb-6">
{t("cta.title")}
</h2>
<p className="text-slate-400 mb-8 max-w-2xl mx-auto">
<p className="text-muted-foreground mb-8 max-w-2xl mx-auto">
{t("cta.subtitle")}
</p>
<Button
asChild
size="lg"
className="bg-primary text-[#0A0A0B] font-bold hover:bg-primary/90"
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
>
<Link href="/auth/register">{t("cta.button")}</Link>
</Button>
@@ -389,29 +384,29 @@ export function LandingPage() {
</main>
{/* Footer */}
<footer className="border-t border-[#27272A] bg-[#0A0A0B] py-12">
<footer className="border-t border-border bg-background py-12">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center rounded-lg bg-primary/10 p-1">
<Languages className="h-4 w-4 text-primary" />
</div>
<span className="text-white font-bold text-lg">
<span className="text-foreground font-bold text-lg">
Office Translator
</span>
</div>
<div className="flex gap-6 text-sm text-slate-400">
<a className="hover:text-white transition-colors" href="#">
<div className="flex gap-6 text-sm text-muted-foreground">
<a className="hover:text-foreground transition-colors" href="#">
{t("footer.privacy")}
</a>
<a className="hover:text-white transition-colors" href="#">
<a className="hover:text-foreground transition-colors" href="#">
{t("footer.terms")}
</a>
<a className="hover:text-white transition-colors" href="#">
<a className="hover:text-foreground transition-colors" href="#">
{t("footer.contact")}
</a>
</div>
<div className="text-slate-500 text-sm">{t("footer.copyright")}</div>
<div className="text-muted-foreground text-sm">{t("footer.copyright")}</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,43 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ThemeToggleProps = {
className?: string;
};
export function ThemeToggle({ className }: ThemeToggleProps) {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
const isDark = mounted && (resolvedTheme === "dark" || theme === "dark");
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-9 shrink-0 text-foreground", className)}
aria-label={isDark ? "Passer en mode clair" : "Passer en mode sombre"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{mounted ? (
isDark ? (
<Sun className="size-4" aria-hidden />
) : (
<Moon className="size-4" aria-hidden />
)
) : (
<span className="size-4 inline-block" aria-hidden />
)}
</Button>
);
}

View File

@@ -12,7 +12,10 @@
"free": "Free",
"popular": "Popular",
"month": "month",
"onRequest": "On Request"
"onRequest": "On Request",
"monthly": "Monthly",
"yearly": "Yearly",
"save20": "Save 20%"
},
"nav": {
"features": "Features",
@@ -62,35 +65,43 @@
"pricing": {
"title": "Simple and Transparent Pricing",
"subtitle": "Start for free, scale as you need.",
"free": {
"name": "Free",
"description": "For occasional needs.",
"price": "$0",
"starter": {
"name": "Starter",
"description": "For individuals and small projects.",
"priceMonthly": "$9",
"priceYearly": "$7.20",
"billedYearly": "Billed $86.40 / year",
"features": [
"5 documents / month",
"Word & Excel only",
"Community support"
"50 documents / month",
"Standard translation (Google/DeepL)",
"Files up to 10 MB",
"Email support"
]
},
"pro": {
"name": "Pro",
"description": "For demanding professionals.",
"price": "$29",
"description": "For growing professionals.",
"priceMonthly": "$19",
"priceYearly": "$15.20",
"billedYearly": "Billed $182.40 / year",
"features": [
"50 documents / month",
"All formats (PPTX included)",
"Advanced AI models (GPT-4)",
"Priority support"
"200 documents / month",
"Essential AI (DeepSeek V3)",
"Files up to 25 MB",
"Custom glossaries"
]
},
"enterprise": {
"name": "Enterprise",
"business": {
"name": "Business",
"description": "For large teams.",
"priceMonthly": "$49",
"priceYearly": "$39.20",
"billedYearly": "Billed $470.40 / year",
"features": [
"Unlimited documents",
"API Access",
"SSO & Advanced Security",
"Dedicated Account Manager"
"1,000 documents / month",
"Premium AI (Claude Haiku)",
"API Access (10K calls)",
"Dedicated support"
]
}
},
@@ -103,10 +114,277 @@
"privacy": "Privacy",
"terms": "Terms",
"contact": "Contact",
"copyright": "© 2024 Office Translator. All rights reserved."
"copyright": "© 2026 Office Translator. All rights reserved."
},
"cookieConsent": {
"title": "Cookies on Office Translator",
"description": "We use essential cookies so the app works (session, security, language). With your permission, we also use optional cookies to measure traffic and improve the product. Choose “Accept all” to allow optional cookies, or “Essential only” to refuse them.",
"acceptAll": "Accept all",
"essentialOnly": "Essential only",
"learnMore": "Learn more on our site",
"footerHint": "You can clear site data in your browser at any time to see this message again."
},
"glossaries": {
"title": "Glossaries",
"description": "Manage custom terminology for your LLM translations.",
"createNew": "Create New Glossary",
"yourGlossaries": "Your Glossaries",
"yourGlossariesDesc": "Create and manage glossaries for consistent translations",
"count_one": "{count} glossary",
"count_other": "{count} glossaries",
"defineTerms": "Define term pairs to customize your LLM translations",
"empty": "No glossaries yet",
"emptyDesc": "Create your first glossary to customize translations",
"aboutTitle": "About Glossaries",
"aboutDesc": "Glossaries let you define custom terminology for your translations. When using LLM translation modes, your terms will be applied to ensure consistent translations.",
"aboutFormat": "Format: Each term has a source (original) and target (translation) pair.",
"dialog": {
"title": "New Glossary",
"description": "Import a predefined template, upload a file with your industry jargon, or enter terms manually.",
"nameLabel": "Glossary name",
"namePlaceholder": "e.g. Legal Glossary FR-EN",
"tabTemplates": "Predefined templates",
"tabFile": "Import file",
"tabManual": "Manual entry",
"templatesDesc": "Choose a predefined glossary covering your industry's terminology.",
"templatesEmpty": "No templates available.",
"terms": "terms",
"cancel": "Cancel",
"importing": "Importing…",
"creating": "Creating…",
"importBtn": "Import ({count} terms)",
"createBtn": "Create ({count} terms)",
"createEmpty": "Create glossary",
"selectPrompt": "Select a template",
"dropTitle": "Drop your file here",
"dropOr": "or click to browse",
"dropFormats": "CSV, XLSX, XLS, ODS • max 500 terms",
"parsing": "Parsing file…",
"termsImported": "{count} terms imported",
"changeFile": "Change file",
"retry": "Retry",
"formatTitle": "Expected format:",
"formatDesc": "Two columns with headers:",
"formatNote": "The first row can be a header (source/target) or raw data.",
"errorFormat": "Unsupported format. Use CSV, XLSX, XLS or ODS.",
"errorSize": "File too large (max {max} MB). Split into smaller files.",
"errorEmpty": "No valid terms found. Check that your file has two columns (source, target).",
"errorRead": "Unable to read the file. Check the format."
},
"toast": {
"created": "Glossary created",
"createdDesc": "\"{name}\" has been created successfully.",
"imported": "Template imported",
"importedDesc": "\"{name}\" has been imported successfully.",
"updated": "Glossary updated",
"updatedDesc": "\"{name}\" has been updated successfully.",
"deleted": "Glossary deleted",
"deletedDesc": "The glossary has been deleted successfully.",
"error": "Error",
"errorCreate": "Failed to create glossary. Please try again.",
"errorImport": "Failed to import template. Please try again.",
"errorUpdate": "Failed to update glossary. Please try again.",
"errorDelete": "Failed to delete glossary. Please try again."
},
"card": {
"term": "term",
"terms": "terms",
"created": "Created"
},
"termEditor": {
"sourceTerm": "Source Term",
"targetTranslation": "Target Translation",
"addTerm": "Add Term",
"maxReached": "Maximum {max} terms per glossary reached."
}
},
"auth": {
"loading": "Loading…",
"or": "or",
"brandName": "Office Translator",
"google": {
"continue": "Continue with Google",
"signUp": "Sign up with Google",
"connecting": "Connecting…",
"errorGeneric": "Something went wrong with Google sign-in.",
"errorFailed": "Google sign-in failed. Please try again.",
"devHint": "To enable Google, add {envKey} in {envFile} (see {exampleFile})."
},
"login": {
"title": "Welcome back",
"subtitle": "Sign in to keep translating",
"email": "Email",
"password": "Password",
"emailPlaceholder": "you@example.com",
"forgotPassword": "Forgot password?",
"signingIn": "Signing in…",
"signIn": "Sign in",
"errorTitle": "Sign-in error",
"noAccount": "Don't have an account?",
"signUpFree": "Sign up for free"
},
"register": {
"title": "Create an account",
"subtitle": "Start translating for free",
"name": "Name",
"namePlaceholder": "Your name",
"email": "Email address",
"emailPlaceholder": "you@example.com",
"password": "Password",
"confirmPassword": "Confirm password",
"createAccount": "Create my account",
"creating": "Creating account…",
"hasAccount": "Already have an account?",
"signIn": "Sign in",
"termsPrefix": "By creating an account, you agree to our",
"termsService": "terms of service",
"registerFailed": "Registration failed",
"errors": {
"nameMin": "Name must be at least 2 characters",
"emailInvalid": "Invalid email address",
"passwordShort": "Password too short (minimum 8 characters)",
"passwordMismatch": "Passwords do not match"
},
"passwordStrength": {
"label": "Strength",
"weak": "Weak",
"medium": "Medium",
"strong": "Strong"
},
"toggle": {
"hide": "Hide",
"show": "Show",
"hidePassword": "Hide password",
"showPassword": "Show password"
}
}
},
"dashboard": {
"nav": {
"overview": "Overview",
"profile": "My profile",
"translate": "Translate",
"apiKeys": "API keys",
"glossaries": "Glossaries"
},
"header": {
"title": "Dashboard",
"subtitle": "Manage your API and translation settings",
"toggleMenu": "Toggle menu",
"profileTitle": "My profile"
},
"sidebar": {
"theme": "Theme",
"signOut": "Sign out",
"backHome": "Back to home"
},
"tier": {
"free": "Free",
"starter": "Starter",
"pro": "Pro",
"business": "Business",
"enterprise": "Enterprise"
},
"translate": {
"errorNotificationTitle": "Translation error",
"pageTitle": "Translate document",
"pageSubtitle": "Upload an Excel, Word, or PowerPoint file, choose languages and provider, then download your translated file — layout preserved.",
"document": {
"title": "Document",
"descReady": "File ready — you can replace it or continue below.",
"descEmpty": "Drag and drop or browse. We support Office Open XML formats.",
"supportedFormats": "Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)"
},
"settings": {
"title": "Translation settings",
"description": "Languages and engine. LLM options require Pro when applicable.",
"howItWorks": "How it works",
"step1": "Add your file in the panel on the left (or above on small screens).",
"step2": "Pick source and target languages.",
"step3": "Choose a translation provider, then run the job.",
"runHint": "When you are ready, we upload the file and start translation. You can cancel while it runs."
},
"actions": {
"translate": "Translate document",
"uploading": "Uploading…",
"cancel": "Cancel",
"tryAgain": "Try again",
"statusComplete": "Translation complete",
"statusIssue": "Translation issue",
"statusProgress": "In progress",
"filePrefix": "File:"
},
"trust": {
"zeroRetention": "Zero data retention",
"deletedAfter": "Files deleted after 60 minutes"
},
"dropzone": {
"title": "Drag & drop your .xlsx, .docx, or .pptx file here",
"subtitle": "or click to browse",
"uploadAria": "Upload file",
"replaceFile": "Replace file"
},
"language": {
"loadErrorPrefix": "Failed to load languages:",
"source": "Source language",
"target": "Target language",
"loading": "Loading…",
"autoDetect": "Auto-detect",
"selectPlaceholder": "Select language"
},
"provider": {
"loading": "Loading providers…",
"noneConfigured": "No providers are configured. Ask your administrator to enable at least one in the admin settings.",
"sectionTitle": "Translation provider",
"llmDivider": "LLM · Context-aware",
"llmDividerPro": "Pro",
"upgrade": "Upgrade to Pro",
"upgradeSuffix": "to use LLM-powered translation.",
"modelTitle": "Model (set by admin):"
},
"progress": {
"failedTitle": "Translation failed",
"processingFallback": "Processing…",
"connectionLost": "Connection lost. Retrying…",
"timeSeconds": "{seconds}s remaining",
"timeMinutes": "{minutes} min remaining",
"timeMixed": "{minutes}m {seconds}s remaining",
"ariaProgress": "Translation progress"
},
"complete": {
"title": "Translation complete!",
"descNamed": "“{name}” has been translated successfully.",
"descGeneric": "Your document has been translated successfully.",
"downloading": "Downloading…",
"download": "Download translated file",
"newTranslation": "New translation",
"toastOkTitle": "Download complete",
"toastOkDesc": "{name} has been downloaded successfully.",
"toastFailTitle": "Download failed",
"toastFailDesc": "Could not download the translated file."
},
"steps": {
"uploading": "Uploading file…",
"starting": "Starting translation…",
"translating": "Translating…"
},
"errors": {
"jobNotFound": "Translation job not found",
"translationFailed": "Translation failed",
"connectionLost": "Lost connection to the translation service. Check your internet connection and try again."
}
}
},
"landing": {
"originalLabel": "Original",
"translatedLabel": "Translated"
"heroDoc": {
"fileName": "Technical_Brief_HVAC.docx",
"ribbonFile": "File",
"ribbonHome": "Home",
"ribbonInsert": "Insert",
"ribbonLayout": "Layout",
"badgeOriginal": "Original (EN)",
"badgeTranslated": "Translation (FR)",
"caption": "One Word document: English on the left, French on the right — same layout preserved."
}
}
}

View File

@@ -12,7 +12,10 @@
"free": "Gratuit",
"popular": "Populaire",
"month": "mois",
"onRequest": "Sur devis"
"onRequest": "Sur devis",
"monthly": "Mensuel",
"yearly": "Annuel",
"save20": "20 %"
},
"nav": {
"features": "Fonctionnalités",
@@ -62,35 +65,43 @@
"pricing": {
"title": "Tarification simple et transparente",
"subtitle": "Commencez gratuitement, évoluez selon vos besoins.",
"free": {
"name": "Gratuit",
"description": "Pour les besoins ponctuels.",
"price": "0€",
"starter": {
"name": "Starter",
"description": "Pour les particuliers et petits projets.",
"priceMonthly": "9€",
"priceYearly": "7.20€",
"billedYearly": "Facturé 86.40€ / an",
"features": [
"5 documents / mois",
"Word & Excel uniquement",
"Support communautaire"
"50 documents / mois",
"Traductions standards (Google/DeepL)",
"Fichiers jusqu'à 10 Mo",
"Support e-mail"
]
},
"pro": {
"name": "Pro",
"description": "Pour les professionnels exigeants.",
"price": "29€",
"description": "Pour les professionnels en croissance.",
"priceMonthly": "19€",
"priceYearly": "15.20€",
"billedYearly": "Facturé 182.40€ / an",
"features": [
"50 documents / mois",
"Tous formats (PPTX inclus)",
"Modèles IA avancés (GPT-4)",
"Support prioritaire"
"200 documents / mois",
"IA Essentielle (DeepSeek V3)",
"Fichiers jusqu'à 25 Mo",
"Glossaires personnalisés"
]
},
"enterprise": {
"name": "Entreprise",
"business": {
"name": "Business",
"description": "Pour les grandes équipes.",
"priceMonthly": "49€",
"priceYearly": "39.20€",
"billedYearly": "Facturé 470.40€ / an",
"features": [
"Documents illimités",
"Accès API",
"SSO & Sécurité avancée",
"Manager de compte dédié"
"1 000 documents / mois",
"IA Premium (Claude Haiku)",
"Accès API (10K appels)",
"Support dédié"
]
}
},
@@ -103,10 +114,277 @@
"privacy": "Confidentialité",
"terms": "CGU",
"contact": "Contact",
"copyright": "© 2024 Office Translator. Tous droits réservés."
"copyright": "© 2026 Office Translator. Tous droits réservés."
},
"cookieConsent": {
"title": "Cookies sur Office Translator",
"description": "Nous utilisons des cookies strictement nécessaires au fonctionnement du service (session, sécurité, langue). Avec votre accord, nous pouvons aussi utiliser des cookies optionnels pour mesurer laudience et améliorer le produit. Choisissez « Tout accepter » pour les autoriser, ou « Nécessaires uniquement » pour les refuser.",
"acceptAll": "Tout accepter",
"essentialOnly": "Nécessaires uniquement",
"learnMore": "En savoir plus sur notre site",
"footerHint": "Vous pouvez effacer les données du site dans votre navigateur à tout moment pour revoir ce message."
},
"glossaries": {
"title": "Glossaires",
"description": "Gérez la terminologie personnalisée pour vos traductions LLM.",
"createNew": "Créer un glossaire",
"yourGlossaries": "Vos glossaires",
"yourGlossariesDesc": "Créez et gérez des glossaires pour des traductions cohérentes",
"count_one": "{count} glossaire",
"count_other": "{count} glossaires",
"defineTerms": "Définissez des paires de termes pour personnaliser vos traductions LLM",
"empty": "Aucun glossaire",
"emptyDesc": "Créez votre premier glossaire pour personnaliser les traductions",
"aboutTitle": "À propos des glossaires",
"aboutDesc": "Les glossaires vous permettent de définir une terminologie personnalisée. En mode traduction LLM, vos termes seront appliqués pour garantir des traductions cohérentes.",
"aboutFormat": "Format : chaque terme possède une source (original) et une cible (traduction).",
"dialog": {
"title": "Nouveau glossaire",
"description": "Importez un template prédéfini, un fichier avec votre jargon métier, ou saisissez vos termes manuellement.",
"nameLabel": "Nom du glossaire",
"namePlaceholder": "ex : Glossaire Juridique FR-EN",
"tabTemplates": "Templates prédéfinis",
"tabFile": "Importer un fichier",
"tabManual": "Saisie manuelle",
"templatesDesc": "Choisissez un glossaire prédéfini couvrant la terminologie de votre domaine.",
"templatesEmpty": "Aucun template disponible.",
"terms": "termes",
"cancel": "Annuler",
"importing": "Importation…",
"creating": "Création…",
"importBtn": "Importer ({count} termes)",
"createBtn": "Créer ({count} termes)",
"createEmpty": "Créer le glossaire",
"selectPrompt": "Sélectionnez un template",
"dropTitle": "Glissez-déposez votre fichier ici",
"dropOr": "ou cliquez pour parcourir",
"dropFormats": "CSV, XLSX, XLS, ODS • max 500 termes",
"parsing": "Analyse du fichier…",
"termsImported": "{count} termes importés",
"changeFile": "Changer de fichier",
"retry": "Réessayer",
"formatTitle": "Format attendu :",
"formatDesc": "Deux colonnes avec en-têtes :",
"formatNote": "La première ligne peut être un en-tête (source/target) ou directement des données.",
"errorFormat": "Format non supporté. Utilisez CSV, XLSX, XLS ou ODS.",
"errorSize": "Fichier trop volumineux (max {max} Mo). Divisez le fichier.",
"errorEmpty": "Aucun terme valide trouvé. Vérifiez les deux colonnes (source, cible).",
"errorRead": "Impossible de lire le fichier. Vérifiez le format."
},
"toast": {
"created": "Glossaire créé",
"createdDesc": "« {name} » a été créé avec succès.",
"imported": "Template importé",
"importedDesc": "« {name} » a été importé avec succès.",
"updated": "Glossaire mis à jour",
"updatedDesc": "« {name} » a été mis à jour avec succès.",
"deleted": "Glossaire supprimé",
"deletedDesc": "Le glossaire a été supprimé avec succès.",
"error": "Erreur",
"errorCreate": "Impossible de créer le glossaire. Veuillez réessayer.",
"errorImport": "Impossible d'importer le template. Veuillez réessayer.",
"errorUpdate": "Impossible de mettre à jour le glossaire. Veuillez réessayer.",
"errorDelete": "Impossible de supprimer le glossaire. Veuillez réessayer."
},
"card": {
"term": "terme",
"terms": "termes",
"created": "Créé le"
},
"termEditor": {
"sourceTerm": "Terme source",
"targetTranslation": "Traduction cible",
"addTerm": "Ajouter un terme",
"maxReached": "Maximum {max} termes par glossaire atteint."
}
},
"auth": {
"loading": "Chargement…",
"or": "ou",
"brandName": "Office Translator",
"google": {
"continue": "Continuer avec Google",
"signUp": "S'inscrire avec Google",
"connecting": "Connexion…",
"errorGeneric": "Erreur lors de la connexion Google.",
"errorFailed": "La connexion Google a échoué. Veuillez réessayer.",
"devHint": "Pour activer Google : ajoutez {envKey} dans {envFile} (voir {exampleFile})."
},
"login": {
"title": "Bon retour",
"subtitle": "Connectez-vous pour continuer à traduire",
"email": "E-mail",
"password": "Mot de passe",
"emailPlaceholder": "vous@exemple.com",
"forgotPassword": "Mot de passe oublié ?",
"signingIn": "Connexion…",
"signIn": "Se connecter",
"errorTitle": "Erreur de connexion",
"noAccount": "Pas encore de compte ?",
"signUpFree": "Créer un compte gratuitement"
},
"register": {
"title": "Créer un compte",
"subtitle": "Commencez à traduire gratuitement",
"name": "Nom",
"namePlaceholder": "Votre nom",
"email": "Adresse e-mail",
"emailPlaceholder": "vous@exemple.com",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"createAccount": "Créer mon compte",
"creating": "Création du compte…",
"hasAccount": "Vous avez déjà un compte ?",
"signIn": "Se connecter",
"termsPrefix": "En créant un compte, vous acceptez notre",
"termsService": "conditions d'utilisation du service",
"registerFailed": "L'inscription a échoué",
"errors": {
"nameMin": "Le nom doit contenir au moins 2 caractères",
"emailInvalid": "Adresse e-mail invalide",
"passwordShort": "Mot de passe trop court (minimum 8 caractères)",
"passwordMismatch": "Les mots de passe ne correspondent pas"
},
"passwordStrength": {
"label": "Force",
"weak": "Faible",
"medium": "Moyen",
"strong": "Fort"
},
"toggle": {
"hide": "Masquer",
"show": "Afficher",
"hidePassword": "Masquer le mot de passe",
"showPassword": "Afficher le mot de passe"
}
}
},
"dashboard": {
"nav": {
"overview": "Vue densemble",
"profile": "Mon profil",
"translate": "Traduire",
"apiKeys": "Clés API",
"glossaries": "Glossaires"
},
"header": {
"title": "Tableau de bord",
"subtitle": "Gérez votre API et vos paramètres de traduction",
"toggleMenu": "Ouvrir le menu",
"profileTitle": "Mon profil"
},
"sidebar": {
"theme": "Thème",
"signOut": "Déconnexion",
"backHome": "Retour à laccueil"
},
"tier": {
"free": "Gratuit",
"starter": "Starter",
"pro": "Pro",
"business": "Business",
"enterprise": "Entreprise"
},
"translate": {
"errorNotificationTitle": "Erreur de traduction",
"pageTitle": "Traduire un document",
"pageSubtitle": "Importez un fichier Excel, Word ou PowerPoint, choisissez les langues et le moteur, puis téléchargez le document traduit — mise en page conservée.",
"document": {
"title": "Document",
"descReady": "Fichier prêt — vous pouvez le remplacer ou continuer ci-dessous.",
"descEmpty": "Glissez-déposez ou parcourez vos fichiers. Formats Office Open XML pris en charge.",
"supportedFormats": "Formats pris en charge : Excel (.xlsx), Word (.docx), PowerPoint (.pptx)"
},
"settings": {
"title": "Paramètres de traduction",
"description": "Langues et moteur. Les options LLM nécessitent Pro le cas échéant.",
"howItWorks": "Comment ça marche",
"step1": "Ajoutez votre fichier dans le panneau de gauche (ou au-dessus sur petit écran).",
"step2": "Choisissez la langue source et la langue cible.",
"step3": "Sélectionnez un fournisseur de traduction, puis lancez la tâche.",
"runHint": "Quand vous êtes prêt, nous envoyons le fichier et démarrons la traduction. Vous pouvez annuler pendant lexécution."
},
"actions": {
"translate": "Traduire le document",
"uploading": "Envoi en cours…",
"cancel": "Annuler",
"tryAgain": "Réessayer",
"statusComplete": "Traduction terminée",
"statusIssue": "Problème de traduction",
"statusProgress": "En cours",
"filePrefix": "Fichier :"
},
"trust": {
"zeroRetention": "Aucune conservation des données",
"deletedAfter": "Fichiers supprimés après 60 minutes"
},
"dropzone": {
"title": "Glissez-déposez votre fichier .xlsx, .docx ou .pptx ici",
"subtitle": "ou cliquez pour choisir un fichier",
"uploadAria": "Téléverser un fichier",
"replaceFile": "Remplacer le fichier"
},
"language": {
"loadErrorPrefix": "Impossible de charger les langues :",
"source": "Langue source",
"target": "Langue cible",
"loading": "Chargement…",
"autoDetect": "Détection automatique",
"selectPlaceholder": "Choisir une langue"
},
"provider": {
"loading": "Chargement des fournisseurs…",
"noneConfigured": "Aucun fournisseur nest configuré. Demandez à un administrateur den activer au moins un dans les réglages admin.",
"sectionTitle": "Fournisseur de traduction",
"llmDivider": "LLM · Contextuel",
"llmDividerPro": "Pro",
"upgrade": "Passer à Pro",
"upgradeSuffix": "pour utiliser la traduction par LLM.",
"modelTitle": "Modèle (défini par ladmin) :"
},
"progress": {
"failedTitle": "Échec de la traduction",
"processingFallback": "Traitement…",
"connectionLost": "Connexion perdue. Nouvelle tentative…",
"timeSeconds": "{seconds} s restantes",
"timeMinutes": "{minutes} min restantes",
"timeMixed": "{minutes} min {seconds} s restantes",
"ariaProgress": "Progression de la traduction"
},
"complete": {
"title": "Traduction terminée !",
"descNamed": "« {name} » a été traduit avec succès.",
"descGeneric": "Votre document a été traduit avec succès.",
"downloading": "Téléchargement…",
"download": "Télécharger le fichier traduit",
"newTranslation": "Nouvelle traduction",
"toastOkTitle": "Téléchargement terminé",
"toastOkDesc": "{name} a été téléchargé avec succès.",
"toastFailTitle": "Échec du téléchargement",
"toastFailDesc": "Impossible de télécharger le fichier traduit."
},
"steps": {
"uploading": "Envoi du fichier…",
"starting": "Démarrage de la traduction…",
"translating": "Traduction en cours…"
},
"errors": {
"jobNotFound": "Tâche de traduction introuvable",
"translationFailed": "La traduction a échoué",
"connectionLost": "Connexion au service de traduction perdue. Vérifiez votre connexion internet et réessayez."
}
}
},
"landing": {
"originalLabel": "Original",
"translatedLabel": "Traduit"
"heroDoc": {
"fileName": "Note_technique_CVC.docx",
"ribbonFile": "Fichier",
"ribbonHome": "Accueil",
"ribbonInsert": "Insertion",
"ribbonLayout": "Mise en page",
"badgeOriginal": "Original (EN)",
"badgeTranslated": "Traduction (FR)",
"caption": "Un seul fichier Word : langlais à gauche, le français à droite — la mise en forme reste identique."
}
}
}
}

View File

@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}