feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
import { Sidebar } from "@/components/sidebar"
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<main className="ml-64 min-h-screen p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Server,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Terminal,
|
||||
Cpu,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
size: string;
|
||||
quantization: string;
|
||||
}
|
||||
|
||||
const recommendedModels: OllamaModel[] = [
|
||||
{ name: "llama3.2:3b", size: "2 GB", quantization: "Q4_0" },
|
||||
{ name: "mistral:7b", size: "4.1 GB", quantization: "Q4_0" },
|
||||
{ name: "qwen2.5:7b", size: "4.7 GB", quantization: "Q4_K_M" },
|
||||
{ name: "llama3.1:8b", size: "4.7 GB", quantization: "Q4_0" },
|
||||
{ name: "gemma2:9b", size: "5.4 GB", quantization: "Q4_0" },
|
||||
];
|
||||
|
||||
export default function OllamaSetupPage() {
|
||||
const [endpoint, setEndpoint] = useState("http://localhost:11434");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setConnectionStatus("idle");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
// Test connection to Ollama
|
||||
const res = await fetch(`${endpoint}/api/tags`);
|
||||
if (!res.ok) throw new Error("Failed to connect to Ollama");
|
||||
|
||||
const data = await res.json();
|
||||
const models = data.models?.map((m: any) => m.name) || [];
|
||||
setAvailableModels(models);
|
||||
setConnectionStatus("success");
|
||||
|
||||
// Auto-select first model if available
|
||||
if (models.length > 0 && !selectedModel) {
|
||||
setSelectedModel(models[0]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setConnectionStatus("error");
|
||||
setErrorMessage(error.message || "Failed to connect to Ollama");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
// Save to localStorage or user settings
|
||||
const settings = { ollamaEndpoint: endpoint, ollamaModel: selectedModel };
|
||||
localStorage.setItem("ollamaSettings", JSON.stringify(settings));
|
||||
|
||||
// Also save to user account if logged in
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
fetch("http://localhost:8000/api/auth/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ollama_endpoint: endpoint,
|
||||
ollama_model: selectedModel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
alert("Settings saved successfully!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<Badge className="mb-4 bg-orange-500/20 text-orange-400 border-orange-500/30">
|
||||
Self-Hosted
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-4">
|
||||
Configure Your Ollama Server
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
|
||||
Connect your own Ollama instance for unlimited, free translations using local AI models.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What is Ollama */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-orange-400" />
|
||||
What is Ollama?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-4">
|
||||
Ollama is a free, open-source tool that lets you run large language models locally on your computer.
|
||||
With Ollama, you can translate documents without sending data to external servers, ensuring complete privacy.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<a
|
||||
href="https://ollama.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
Visit Ollama Website
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ollama/ollama"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
GitHub Repository
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Guide */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-blue-400" />
|
||||
Quick Installation Guide
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Step 1 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">1. Install Ollama</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<div>
|
||||
<span className="text-zinc-400">macOS / Linux:</span>
|
||||
<code className="ml-2 text-teal-400">curl -fsSL https://ollama.ai/install.sh | sh</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard("curl -fsSL https://ollama.ai/install.sh | sh", "install")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "install" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Windows: Download from <a href="https://ollama.ai/download" className="text-teal-400 hover:underline" target="_blank">ollama.ai/download</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">2. Pull a Translation Model</h3>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<code className="text-teal-400">ollama pull llama3.2:3b</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard("ollama pull llama3.2:3b", "pull")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "pull" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">3. Start Ollama Server</h3>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<code className="text-teal-400">ollama serve</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard("ollama serve", "serve")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "serve" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-2">
|
||||
On macOS/Windows with the desktop app, Ollama runs automatically in the background.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Models */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-purple-400" />
|
||||
Recommended Models for Translation
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedModels.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-zinc-800"
|
||||
>
|
||||
<div>
|
||||
<span className="text-white font-medium">{model.name}</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs border-zinc-700 text-zinc-400">
|
||||
<HardDrive className="h-3 w-3 mr-1" />
|
||||
{model.size}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`ollama pull ${model.name}`, model.name)}
|
||||
className="px-3 py-1.5 rounded bg-zinc-700 hover:bg-zinc-600 text-sm text-white"
|
||||
>
|
||||
{copied === model.name ? "Copied!" : "Copy command"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-4">
|
||||
💡 Tip: For best results with limited RAM (8GB), use <code className="text-teal-400">llama3.2:3b</code>.
|
||||
With 16GB+ RAM, try <code className="text-teal-400">mistral:7b</code> or larger.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Configure Connection</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint" className="text-zinc-300">Ollama Server URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="endpoint"
|
||||
type="url"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
/>
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
disabled={testing}
|
||||
className="bg-teal-500 hover:bg-teal-600 text-white whitespace-nowrap"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
{connectionStatus === "success" && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-2">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-green-400">Connected successfully! Found {availableModels.length} model(s).</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionStatus === "error" && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<span className="text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
{availableModels.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-zinc-300">Select Model</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{availableModels.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
onClick={() => setSelectedModel(model)}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border text-left transition-colors",
|
||||
selectedModel === model
|
||||
? "border-teal-500 bg-teal-500/10 text-teal-400"
|
||||
: "border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-600"
|
||||
)}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
{connectionStatus === "success" && selectedModel && (
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||
>
|
||||
Save Configuration
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-gradient-to-r from-teal-500/10 to-purple-500/10 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Why Self-Host?</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">🔒</div>
|
||||
<h3 className="font-medium text-white mb-1">Complete Privacy</h3>
|
||||
<p className="text-sm text-zinc-400">Your documents never leave your computer</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">♾️</div>
|
||||
<h3 className="font-medium text-white mb-1">Unlimited Usage</h3>
|
||||
<p className="text-sm text-zinc-400">No monthly limits or quotas</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">💰</div>
|
||||
<h3 className="font-medium text-white mb-1">Free Forever</h3>
|
||||
<p className="text-sm text-zinc-400">No subscription or API costs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, ArrowRight, AlertCircle, CheckCircle, Zap } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ContextGlossaryPage() {
|
||||
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [localSettings, setLocalSettings] = useState({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings(localSettings);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => {
|
||||
applyPreset(preset);
|
||||
// Need to get updated values from store after applying preset
|
||||
setTimeout(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
||||
glossary: useTranslationStore.getState().settings.glossary,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearContext();
|
||||
setLocalSettings({
|
||||
systemPrompt: "",
|
||||
glossary: "",
|
||||
});
|
||||
};
|
||||
|
||||
// Check which LLM providers are configured
|
||||
const isOllamaConfigured = settings.ollamaUrl && settings.ollamaModel;
|
||||
const isOpenAIConfigured = !!settings.openaiApiKey;
|
||||
const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Brain className="h-3 w-3 mr-1" />
|
||||
Context & Glossary
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">
|
||||
Context & Glossary
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Configure translation context and glossary for LLM-based providers
|
||||
</p>
|
||||
|
||||
{/* LLM Provider Status */}
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isOllamaConfigured
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOllamaConfigured && <CheckCircle className="h-4 w-4" />}
|
||||
<span>🤖 Ollama</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isOpenAIConfigured
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpenAIConfigured && <CheckCircle className="h-4 w-4" />}
|
||||
<span>🧠 OpenAI</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isWebLLMAvailable
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWebLLMAvailable && <CheckCircle className="h-4 w-4" />}
|
||||
<span>💻 WebLLM</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card variant="gradient" className="mb-8 animate-fade-in-up">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Context & Glossary Settings
|
||||
</h3>
|
||||
<p className="text-text-secondary leading-relaxed">
|
||||
These settings apply to all LLM providers: <strong>Ollama</strong>, <strong>OpenAI</strong>, and <strong>WebLLM</strong>.
|
||||
Use them to improve translation quality with domain-specific instructions and terminology.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Left Column - System Prompt */}
|
||||
<div className="space-y-6">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-100">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Brain className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">System Prompt</CardTitle>
|
||||
<CardDescription>
|
||||
Instructions for LLM to follow during translation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
id="system-prompt"
|
||||
value={localSettings.systemPrompt}
|
||||
onChange={(e) =>
|
||||
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-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">
|
||||
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Tip:</strong> Include domain context, tone preferences, or specific terminology rules for better translation accuracy.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Zap className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">Quick Presets</CardTitle>
|
||||
<CardDescription>
|
||||
Load pre-configured prompts & glossaries for common domains
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("hvac")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">🔧</div>
|
||||
<div className="font-medium">HVAC / Engineering</div>
|
||||
<div className="text-xs text-text-tertiary">Technical terminology</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("it")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">💻</div>
|
||||
<div className="font-medium">IT / Software</div>
|
||||
<div className="text-xs text-text-tertiary">Development terms</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("legal")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">⚖️</div>
|
||||
<div className="font-medium">Legal / Contracts</div>
|
||||
<div className="text-xs text-text-tertiary">Legal terminology</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("medical")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">🏥</div>
|
||||
<div className="font-medium">Medical / Healthcare</div>
|
||||
<div className="text-xs text-text-tertiary">Medical terms</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClear}
|
||||
className="w-full text-destructive hover:text-destructive/80 hover:bg-destructive/10 group"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Clear All
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Glossary */}
|
||||
<div className="space-y-6">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/20">
|
||||
<BookOpen className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">Technical Glossary</CardTitle>
|
||||
<CardDescription>
|
||||
Define specific term translations. Format: source=target (one per line)
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
id="glossary"
|
||||
value={localSettings.glossary}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, glossary: e.target.value })
|
||||
}
|
||||
placeholder="pression statique=static pressure récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct diffuseur=diffuser"
|
||||
className="bg-surface border-border-subtle text-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">
|
||||
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Pro Tip:</strong> The glossary is included in system prompt to guide translations and ensure consistent terminology.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage Examples */}
|
||||
<Card variant="glass" className="animate-fade-in-up animation-delay-400">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<AlertCircle className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">Usage Examples</CardTitle>
|
||||
<CardDescription>
|
||||
See how context and glossary improve translations
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-surface/50 border border-border-subtle">
|
||||
<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-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>
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
<strong>Key improvements:</strong> Technical terms are correctly translated, context is preserved, and industry-standard terminology is used.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end animate-fade-in-up animation-delay-500">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-foreground group"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { languages } from "@/lib/api";
|
||||
import { Save, Loader2, Settings, Globe, Trash2, ArrowRight, Shield, Zap, Database } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { settings, updateSettings } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultLanguage(settings.defaultTargetLanguage);
|
||||
}, [settings.defaultTargetLanguage]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings({ defaultTargetLanguage: defaultLanguage });
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('translation-settings');
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
// Clear any cached files/blobs
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// Reload to reset state
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Settings
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">
|
||||
General Settings
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Configure general application settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up">
|
||||
<Link href="/settings/services" className="block">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-primary/20 group-hover:bg-primary/30 transition-colors duration-300">
|
||||
<Zap className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||
Translation Services
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Configure providers</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-primary">
|
||||
<span className="text-sm font-medium">Manage providers</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-100">
|
||||
<Link href="/settings/context" className="block">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-accent/20 group-hover:bg-accent/30 transition-colors duration-300">
|
||||
<Globe className="h-6 w-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-accent transition-colors duration-300">
|
||||
Context & Glossary
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Domain-specific settings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-accent">
|
||||
<span className="text-sm font-medium">Configure context</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-success/20 group-hover:bg-success/30 transition-colors duration-300">
|
||||
<Shield className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-success transition-colors duration-300">
|
||||
Privacy & Security
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Data protection</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-success">
|
||||
<span className="text-sm font-medium">Coming soon</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Application Settings */}
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Application Settings</CardTitle>
|
||||
<CardDescription>
|
||||
General configuration options
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="default-language" className="text-text-secondary font-medium">
|
||||
Default Target Language
|
||||
</Label>
|
||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
|
||||
<SelectValue placeholder="Select default language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border-subtle max-h-[300px]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
This language will be pre-selected when translating documents
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Supported Formats */}
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-400">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Globe className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Supported Formats</CardTitle>
|
||||
<CardDescription>
|
||||
Document types that can be translated
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📊</div>
|
||||
<h3 className="font-semibold text-white mb-2">Excel</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.xlsx, .xls</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Formulas
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Styles
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📝</div>
|
||||
<h3 className="font-semibold text-white mb-2">Word</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.docx, .doc</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Headers
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Tables
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📽️</div>
|
||||
<h3 className="font-semibold text-white mb-2">PowerPoint</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.pptx, .ppt</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Slides
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Notes
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Information */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/20">
|
||||
<Database className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">API Information</CardTitle>
|
||||
<CardDescription>
|
||||
Backend server connection details
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">API Endpoint</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">http://localhost:8000</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">Health Check</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/health</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">Translate Endpoint</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/translate</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-600">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-warning/20">
|
||||
<Shield className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">System Status</CardTitle>
|
||||
<CardDescription>
|
||||
Application health and performance
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Connection Status</span>
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10">
|
||||
<div className="w-2 h-2 bg-success rounded-full mr-2 animate-pulse"></div>
|
||||
Connected
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Last Sync</span>
|
||||
<span className="text-sm text-text-secondary">Just now</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Version</span>
|
||||
<span className="text-sm text-text-secondary">v2.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center animate-fade-in-up animation-delay-700">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Need help with settings? Check our documentation.
|
||||
</p>
|
||||
<Button variant="glass" size="sm" className="group">
|
||||
<Settings className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:rotate-90" />
|
||||
View Documentation
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearing}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10 hover:border-destructive group"
|
||||
>
|
||||
{isClearing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Clearing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Clear Cache
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,694 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
BarChart3, FileText, Layers, Brain, CreditCard,
|
||||
RefreshCw, ExternalLink, ChevronRight, Info,
|
||||
TrendingUp, Calendar, Gauge
|
||||
} 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 { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Types
|
||||
───────────────────────────────────────────── */
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
subscription_status: string;
|
||||
docs_translated_this_month: number;
|
||||
pages_translated_this_month: number;
|
||||
api_calls_this_month: number;
|
||||
extra_credits: number;
|
||||
subscription_ends_at?: string;
|
||||
cancel_at_period_end?: boolean;
|
||||
}
|
||||
|
||||
interface UsageLimits {
|
||||
plan: string;
|
||||
docs_used: number;
|
||||
docs_limit: number;
|
||||
pages_used: number;
|
||||
pages_limit: number;
|
||||
api_calls_used: number;
|
||||
api_calls_limit: number;
|
||||
can_translate: boolean;
|
||||
upgrade_required: boolean;
|
||||
extra_credits: number;
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
max_file_size_mb: number;
|
||||
features: string[];
|
||||
ai_translation: boolean;
|
||||
ai_tier?: string;
|
||||
api_access: boolean;
|
||||
priority_processing: boolean;
|
||||
team_seats?: number;
|
||||
popular?: boolean;
|
||||
badge?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Helpers
|
||||
───────────────────────────────────────────── */
|
||||
const PLAN_ICONS: Record<string, any> = {
|
||||
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
|
||||
};
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
free: "from-slate-600 to-slate-700",
|
||||
starter: "from-blue-600 to-blue-700",
|
||||
pro: "from-violet-600 to-violet-700",
|
||||
business: "from-emerald-600 to-emerald-700",
|
||||
enterprise: "from-amber-600 to-amber-700",
|
||||
};
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: "Gratuit", starter: "Starter", pro: "Pro",
|
||||
business: "Business", enterprise: "Entreprise",
|
||||
};
|
||||
|
||||
function pct(used: number, limit: number) {
|
||||
if (limit === -1) return 0;
|
||||
return Math.min(100, Math.round((used / limit) * 100));
|
||||
}
|
||||
|
||||
function fmtLimit(val: number) {
|
||||
return val === -1 ? "Illimité" : String(val);
|
||||
}
|
||||
|
||||
function UsageBar({
|
||||
label, used, limit, icon,
|
||||
}: { label: string; used: number; limit: number; icon: React.ReactNode }) {
|
||||
const p = pct(used, limit);
|
||||
const isUnlimited = limit === -1;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"font-mono text-xs",
|
||||
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-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-700",
|
||||
p >= 90 ? "bg-destructive" : p >= 70 ? "bg-warning" : "bg-accent"
|
||||
)}
|
||||
style={{ width: `${p}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Main component
|
||||
───────────────────────────────────────────── */
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const targetPlan = searchParams.get("plan");
|
||||
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [usage, setUsage] = useState<UsageLimits | null>(null);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const [loadingPortal, setLoadingPortal] = useState(false);
|
||||
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}` };
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!token) { router.push("/auth/login?redirect=/settings/subscription"); return; }
|
||||
try {
|
||||
const [meRes, usageRes, plansRes] = await Promise.all([
|
||||
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();
|
||||
setUser(j.data ?? j);
|
||||
}
|
||||
if (usageRes.ok) {
|
||||
const j = await usageRes.json();
|
||||
setUsage(j.data ?? j);
|
||||
}
|
||||
if (plansRes.ok) {
|
||||
const j = await plansRes.json();
|
||||
const d = j.data ?? j;
|
||||
if (Array.isArray(d.plans)) setPlans(d.plans);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
|
||||
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 pour le moment." });
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Impossible d'accéder au portail de facturation." });
|
||||
} finally {
|
||||
setLoadingPortal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!cancelConfirm) { setCancelConfirm(true); return; }
|
||||
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. Réessayez ou contactez le support." });
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Erreur réseau." });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
if (planId === "enterprise") {
|
||||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMsg({
|
||||
type: "ok",
|
||||
text: `Création de votre session de paiement pour le forfait ${PLAN_LABELS[planId] ?? planId}…`,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPlanId = user?.plan ?? "free";
|
||||
const currentPlanLabel = PLAN_LABELS[currentPlanId] ?? currentPlanId;
|
||||
const Icon = PLAN_ICONS[currentPlanId] ?? Sparkles;
|
||||
const gradient = PLAN_COLORS[currentPlanId] ?? PLAN_COLORS.free;
|
||||
const currentPlanData = plans.find((p) => p.id === currentPlanId);
|
||||
|
||||
const otherPlans = plans.filter((p) => p.id !== currentPlanId);
|
||||
const upgradePlans = otherPlans.filter((p) => {
|
||||
const order = ["free", "starter", "pro", "business", "enterprise"];
|
||||
return order.indexOf(p.id) > order.indexOf(currentPlanId);
|
||||
});
|
||||
const downgradePlans = otherPlans.filter((p) => {
|
||||
const order = ["free", "starter", "pro", "business", "enterprise"];
|
||||
return order.indexOf(p.id) < order.indexOf(currentPlanId);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-10 space-y-8">
|
||||
<div>
|
||||
<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 */}
|
||||
{statusMsg && (
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-xl border",
|
||||
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-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-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-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)}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-foreground">Forfait {currentPlanLabel}</h2>
|
||||
{user?.subscription_status && (
|
||||
<Badge className={cn(
|
||||
"text-xs",
|
||||
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" :
|
||||
user.subscription_status === "canceled" ? "Annulé" :
|
||||
user.subscription_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user?.subscription_ends_at && (
|
||||
<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" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentPlanId !== "free" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBillingPortal}
|
||||
disabled={loadingPortal}
|
||||
>
|
||||
{loadingPortal ? <RefreshCw className="w-4 h-4 animate-spin mr-1" /> : <CreditCard className="w-4 h-4 mr-1" />}
|
||||
Portail de facturation
|
||||
<ExternalLink className="w-3.5 h-3.5 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
{upgradePlans.length > 0 && (
|
||||
<Button
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground"
|
||||
onClick={() => router.push("/pricing")}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-1" /> Upgrader
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Usage this month ── */}
|
||||
{usage && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-accent" />
|
||||
Utilisation ce mois
|
||||
<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">
|
||||
<UsageBar
|
||||
label="Documents traduits"
|
||||
used={usage.docs_used}
|
||||
limit={usage.docs_limit}
|
||||
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-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-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_credits > 0 && (
|
||||
<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-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-destructive hover:bg-destructive/90 text-destructive-foreground flex-shrink-0" onClick={() => router.push("/pricing")}>
|
||||
Upgrader
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Plan features recap ── */}
|
||||
{currentPlanData && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BadgeCheck className="w-5 h-5 text-success" />
|
||||
Inclus dans votre forfait
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Upgrade options ── */}
|
||||
{upgradePlans.length > 0 && (
|
||||
<div>
|
||||
<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-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-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-foreground text-background" : "text-muted-foreground")}
|
||||
>
|
||||
Annuel
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">−20 %</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{upgradePlans.map((plan) => {
|
||||
const PIcon = PLAN_ICONS[plan.id] ?? Zap;
|
||||
const grad = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
|
||||
const price = plan.price_monthly === -1
|
||||
? null
|
||||
: isYearly
|
||||
? (plan.price_yearly / 12).toFixed(2)
|
||||
: plan.price_monthly.toFixed(2);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"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-accent text-accent-foreground text-xs">{plan.badge}</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("p-4 bg-gradient-to-br", grad)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<PIcon className="w-5 h-5 text-white" />
|
||||
<span className="font-bold text-white">{plan.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-end gap-1">
|
||||
{price === null ? (
|
||||
<span className="text-2xl font-bold text-white">Sur devis</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-white">{price} €</span>
|
||||
<span className="text-white/70 text-sm pb-0.5">/mois</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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-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-muted-foreground">+{plan.features.length - 4} autres avantages…</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
className={cn(
|
||||
"mt-3 w-full py-2 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-2 transition-all",
|
||||
`bg-gradient-to-r ${grad} hover:opacity-90`
|
||||
)}
|
||||
>
|
||||
Passer au forfait {plan.name} <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Buy credits ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-warning" />
|
||||
Crédits supplémentaires
|
||||
</CardTitle>
|
||||
<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">
|
||||
{[
|
||||
{ credits: 50, price: 5 },
|
||||
{ credits: 150, price: 12, popular: true },
|
||||
{ credits: 500, price: 35 },
|
||||
{ credits: 1000, price: 60 },
|
||||
].map((pkg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"relative p-4 rounded-xl border text-center",
|
||||
pkg.popular
|
||||
? "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-warning text-warning-foreground text-xs rounded-full font-bold whitespace-nowrap">
|
||||
Meilleure valeur
|
||||
</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={() => handleCreditsCheckout(i)}
|
||||
className="w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all"
|
||||
>
|
||||
Acheter
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Downgrade / Cancel ── */}
|
||||
{currentPlanId !== "free" && (
|
||||
<Card className="border-destructive/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Zone de danger
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{downgradePlans.length > 0 && (
|
||||
<div>
|
||||
<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-muted hover:bg-secondary text-muted-foreground text-sm border border-border/50 transition-all"
|
||||
>
|
||||
Passer à {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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-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-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setCancelConfirm(true)}
|
||||
>
|
||||
Annuler l'abonnement
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<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-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-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Oui, annuler mon abonnement
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancelConfirm(false)}
|
||||
>
|
||||
Non, conserver
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Link to full pricing */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => router.push("/pricing")}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,13 @@ import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function AdminHeader() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -38,9 +40,9 @@ export function AdminHeader() {
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<h1 className="text-xs font-semibold text-foreground">System Administration</h1>
|
||||
<h1 className="text-xs font-semibold text-foreground">{t('admin.dashboard.title')}</h1>
|
||||
<Separator orientation="vertical" className="h-3" />
|
||||
<span className="text-xs text-muted-foreground">Monitor infrastructure and manage users</span>
|
||||
<span className="text-xs text-muted-foreground">{t('admin.dashboard.subtitle')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -48,7 +50,7 @@ export function AdminHeader() {
|
||||
variant="outline"
|
||||
className="border-destructive/30 bg-destructive/5 text-destructive text-[10px] px-1.5 py-0"
|
||||
>
|
||||
<Shield className="mr-1 size-2.5" />
|
||||
<Shield className="me-1 size-2.5" />
|
||||
Superadmin
|
||||
</Badge>
|
||||
<Avatar className="size-6">
|
||||
@@ -66,7 +68,7 @@ export function AdminHeader() {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
@@ -77,7 +79,7 @@ export function AdminHeader() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,10 +9,12 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
|
||||
@@ -38,7 +40,7 @@ export function AdminSidebar() {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
|
||||
@@ -48,7 +50,7 @@ export function AdminSidebar() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { HeartPulse, HardDrive, FileWarning, Trash2, Loader2 } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { AdminDashboardData } from "./types";
|
||||
|
||||
interface SystemHealthCardsProps {
|
||||
@@ -21,6 +22,7 @@ export function SystemHealthCards({
|
||||
onPurge,
|
||||
purgeResult,
|
||||
}: SystemHealthCardsProps) {
|
||||
const { t } = useI18n();
|
||||
const diskUsed = data?.system?.disk?.used_percent ?? 0;
|
||||
const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0;
|
||||
const systemStatus = data?.status ?? "unhealthy";
|
||||
@@ -82,14 +84,14 @@ export function SystemHealthCards({
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{systemStatus === "healthy"
|
||||
? "All Systems Operational"
|
||||
: "System Issues Detected"}
|
||||
? t('admin.system.allOperational')
|
||||
: t('admin.system.issuesDetected')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{data?.timestamp
|
||||
{data?.timestamp
|
||||
? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}`
|
||||
: "Waiting for data..."}
|
||||
: t('admin.system.waitingData')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -151,10 +153,10 @@ export function SystemHealthCards({
|
||||
<Trash2 className="size-3" />
|
||||
)}
|
||||
{isPurging
|
||||
? "Purging..."
|
||||
? t('admin.system.purging')
|
||||
: trackedFilesCount === 0
|
||||
? "Clean"
|
||||
: "Purge"}
|
||||
? t('admin.system.clean')
|
||||
: t('admin.system.purge')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Rang</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="text-right">Traductions</TableHead>
|
||||
<TableHead className="text-end">Traductions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -120,7 +120,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell>{getRankBadge(index + 1)}</TableCell>
|
||||
<TableCell className="font-medium">{user.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-end">
|
||||
<Badge variant="secondary">{user.translation_count}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AdminNavItem {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ 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 },
|
||||
{ labelKey: 'admin.nav.dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ labelKey: 'admin.nav.users', href: '/admin/users', icon: Users },
|
||||
{ labelKey: 'admin.nav.pricing', href: '/admin/pricing', icon: CreditCard },
|
||||
{ labelKey: 'admin.nav.providers', href: '/admin/settings', icon: Settings },
|
||||
{ labelKey: 'admin.nav.system', href: '/admin/system', icon: Settings },
|
||||
{ labelKey: 'admin.nav.logs', href: '/admin/logs', icon: FileText },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import { AdminHeader } from "./AdminHeader";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
@@ -15,9 +16,9 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { settings, setAdminToken } = useTranslationStore();
|
||||
const { t } = useI18n();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
/** Sans ça, au premier rendu après un chargement complet le token n’est pas encore lu depuis localStorage → fausse absence de token → redirection /admin/login (bug sur F5 ou URL directe). */
|
||||
const [persistHydrated, setPersistHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,7 +76,7 @@ export default function AdminLayout({
|
||||
if (isChecking && pathname !== "/admin/login") {
|
||||
return (
|
||||
<div className="min-h-screen bg-card flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">Vérification de l'authentification...</div>
|
||||
<div className="text-muted-foreground text-sm">{t('admin.layout.checking')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAdminLogin } from "./useAdminLogin";
|
||||
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
function AdminLoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, isLoading, error } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -25,8 +27,8 @@ function AdminLoginContent() {
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-600/20 rounded-2xl mb-4">
|
||||
<Shield className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Administration</h1>
|
||||
<p className="text-gray-400 mt-2">Connexion requise</p>
|
||||
<h1 className="text-2xl font-bold text-white">{t('admin.login.title')}</h1>
|
||||
<p className="text-gray-400 mt-2">{t('admin.login.required')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
||||
@@ -39,7 +41,7 @@ function AdminLoginContent() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Mot de passe administrateur</label>
|
||||
<label className="block text-gray-400 text-sm mb-2">{t('admin.login.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
@@ -47,7 +49,7 @@ function AdminLoginContent() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-12 pr-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
|
||||
className="w-full ps-12 pe-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -69,12 +71,12 @@ function AdminLoginContent() {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connexion...
|
||||
{t('admin.login.connecting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-5 h-5" />
|
||||
Accéder au panneau admin
|
||||
{t('admin.login.access')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -82,7 +84,7 @@ function AdminLoginContent() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
Accès réservé aux administrateurs
|
||||
{t('admin.login.restricted')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,10 +92,11 @@ function AdminLoginContent() {
|
||||
}
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl">Chargement...</div>
|
||||
<div className="text-white text-xl">{t('common.loading')}</div>
|
||||
</div>
|
||||
}>
|
||||
<AdminLoginContent />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SystemHealthCards } from "./SystemHealthCards";
|
||||
import { ProviderStatus } from "./ProviderStatus";
|
||||
import { useAdminDashboard } from "./useAdminDashboard";
|
||||
import { useCleanup } from "./useCleanup";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
export default function AdminPage() {
|
||||
const { data, isLoading, error, refetch } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, triggerCleanup } = useCleanup();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handlePurge = async () => {
|
||||
await triggerCleanup();
|
||||
@@ -32,10 +34,10 @@ export default function AdminPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Dashboard Admin
|
||||
{t('admin.dashboard.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Panneau de contrôle administrateur
|
||||
{t('admin.dashboard.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,11 +56,11 @@ export default function AdminPage() {
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
{t('admin.dashboard.refresh')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Refresh dashboard data</p>
|
||||
<p>{t('admin.dashboard.refreshTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -83,23 +85,23 @@ export default function AdminPage() {
|
||||
{data?.config && (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
System Configuration
|
||||
{t('admin.dashboard.config')}
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Max file size:{" "}
|
||||
{t('admin.dashboard.maxFileSize')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.max_file_size_mb}MB
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Translation service:{" "}
|
||||
{t('admin.dashboard.translationService')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.translation_service}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Formats:{" "}
|
||||
{t('admin.dashboard.formats')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.supported_extensions.join(", ")}
|
||||
</strong>
|
||||
|
||||
@@ -455,7 +455,7 @@ export default function AdminSettingsPage() {
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
<span className="ml-1">Récupérer les modèles</span>
|
||||
<span className="ms-1">Récupérer les modèles</span>
|
||||
</Button>
|
||||
</div>
|
||||
{ollamaModels.length > 0 ? (
|
||||
@@ -471,7 +471,7 @@ export default function AdminSettingsPage() {
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
{model.size > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<span className="ms-2 text-xs text-muted-foreground">
|
||||
({(model.size / 1e9).toFixed(1)} GB)
|
||||
</span>
|
||||
)}
|
||||
@@ -660,13 +660,13 @@ export default function AdminSettingsPage() {
|
||||
className="h-8"
|
||||
>
|
||||
{testResults.smtp === "testing" ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
||||
) : testResults.smtp === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
||||
) : testResults.smtp === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
||||
) : (
|
||||
<><FlaskConical className="size-3 mr-1" />Tester</>
|
||||
<><FlaskConical className="size-3 me-1" />Tester</>
|
||||
)}
|
||||
</Button>
|
||||
<Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} />
|
||||
@@ -752,13 +752,13 @@ export default function AdminSettingsPage() {
|
||||
className="h-8"
|
||||
>
|
||||
{isSendingTestEmail ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Envoi...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Envoi...</>
|
||||
) : testEmailResult === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />Envoyé</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />Envoyé</>
|
||||
) : testEmailResult === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Échec</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Échec</>
|
||||
) : (
|
||||
<><Mail className="size-3 mr-1" />Envoyer un email de test</>
|
||||
<><Mail className="size-3 me-1" />Envoyer un email de test</>
|
||||
)}
|
||||
</Button>
|
||||
{testEmailMessage && (
|
||||
@@ -775,12 +775,12 @@ export default function AdminSettingsPage() {
|
||||
<Button onClick={saveConfig} disabled={isSaving} size="lg">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<Loader2 className="me-2 size-4 animate-spin" />
|
||||
Sauvegarde...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 size-4" />
|
||||
<Save className="me-2 size-4" />
|
||||
Sauvegarder la configuration
|
||||
</>
|
||||
)}
|
||||
@@ -838,13 +838,13 @@ function ProviderCard({
|
||||
className="h-8"
|
||||
>
|
||||
{testResult === "testing" ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
||||
) : testResult === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
||||
) : testResult === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
||||
) : (
|
||||
<><FlaskConical className="size-3 mr-1" />Tester</>
|
||||
<><FlaskConical className="size-3 me-1" />Tester</>
|
||||
)}
|
||||
</Button>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} />
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Settings, AlertCircle, Loader2, RotateCcw, CheckCircle2 } from "lucide-react";
|
||||
import { useSystemPage } from "./useSystemPage";
|
||||
import { CleanupSection } from "./CleanupSection";
|
||||
import { DiskSpaceCard } from "./DiskSpaceCard";
|
||||
import { ProviderStatus } from "../ProviderStatus";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AdminSystemPage() {
|
||||
const { data, isLoading, error, isPurging, purgeResult, handleCleanup } = useSystemPage();
|
||||
const {
|
||||
data, isLoading, error,
|
||||
isPurging, purgeResult, handleCleanup,
|
||||
isResetting, resetResult, handleResetQuotas,
|
||||
} = useSystemPage();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -16,9 +24,9 @@ export default function AdminSystemPage() {
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Système</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.system.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Surveiller l'état du système et gérer les ressources
|
||||
{t('admin.system.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +40,7 @@ export default function AdminSystemPage() {
|
||||
|
||||
{isLoading && !data ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[88px] animate-pulse rounded-lg border border-border bg-card"
|
||||
@@ -52,6 +60,43 @@ export default function AdminSystemPage() {
|
||||
purgeResult={purgeResult}
|
||||
onCleanup={handleCleanup}
|
||||
/>
|
||||
|
||||
{/* Reset Translation Quotas */}
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<RotateCcw className="size-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('admin.system.quotas')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t('admin.system.resetQuotas')}
|
||||
</span>
|
||||
{resetResult && (
|
||||
<span className="text-[10px] text-green-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
{resetResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 border-amber-200/30 text-amber-600 hover:bg-amber-500/10 hover:text-amber-600 text-xs"
|
||||
onClick={handleResetQuotas}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{isResetting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3" />
|
||||
)}
|
||||
{isResetting ? t('admin.system.resetting') : t('admin.system.reset')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAdminDashboard } from "../useAdminDashboard";
|
||||
import { useCleanup } from "../useCleanup";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
|
||||
export function useSystemPage() {
|
||||
const { data, isLoading, error } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, error: cleanupError, triggerCleanup } = useCleanup();
|
||||
const { settings } = useTranslationStore();
|
||||
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetResult, setResetResult] = useState<{ keys_deleted: number; message: string } | null>(null);
|
||||
|
||||
const handleCleanup = () => triggerCleanup();
|
||||
|
||||
const handleResetQuotas = async () => {
|
||||
if (!settings.adminToken) return;
|
||||
setIsResetting(true);
|
||||
setResetResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/quota/reset`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${settings.adminToken}` },
|
||||
});
|
||||
const json = await res.json();
|
||||
setResetResult(json);
|
||||
} catch {
|
||||
setResetResult({ keys_deleted: 0, message: "Erreur réseau" });
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -16,5 +41,8 @@ export function useSystemPage() {
|
||||
isPurging,
|
||||
purgeResult,
|
||||
handleCleanup,
|
||||
isResetting,
|
||||
resetResult,
|
||||
handleResetQuotas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ const statusConfig: Record<string, { label: string; dotClass: string; textClass:
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
@@ -208,7 +208,7 @@ export function UserTable({
|
||||
placeholder="Rechercher par email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
className="h-8 ps-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ export function UserTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 pl-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<TableHead className="h-8 ps-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Email
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
@@ -238,7 +238,7 @@ export function UserTable({
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Clés
|
||||
</TableHead>
|
||||
<TableHead className="h-8 pr-6 text-right text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<TableHead className="h-8 pe-6 text-end text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -255,7 +255,7 @@ export function UserTable({
|
||||
|
||||
return (
|
||||
<TableRow key={user.id} className={`group ${hasError ? "bg-destructive/5" : ""}`}>
|
||||
<TableCell className="pl-6 py-2">
|
||||
<TableCell className="ps-6 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{user.email}
|
||||
@@ -357,7 +357,7 @@ export function UserTable({
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="pr-6 py-2 text-right">
|
||||
<TableCell className="pe-6 py-2 text-end">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRevokeApiKey } from "./useRevokeApiKey";
|
||||
import { UserStats } from "./UserStats";
|
||||
import { UserTable } from "./UserTable";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { PlanType } from "./types";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
@@ -16,19 +17,20 @@ export default function AdminUsersPage() {
|
||||
const { revokeKey, isRevoking } = useRevokeApiKey();
|
||||
const { resetPassword, isResetting } = useAdminResetPassword();
|
||||
const toast = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleTierChange = async (userId: string, plan: PlanType) => {
|
||||
try {
|
||||
await updateTier({ userId, plan });
|
||||
toast.success({
|
||||
title: "Plan mis à jour",
|
||||
description: `Le plan a été changé vers "${plan}" avec succès.`,
|
||||
title: t('admin.users.planUpdated'),
|
||||
description: t('admin.users.planChanged', { plan }),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
const message = err instanceof Error ? err.message : t('admin.users.unknownError');
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de mettre à jour le plan: ${message}`,
|
||||
title: t('admin.users.error'),
|
||||
description: t('admin.users.planUpdateError', { message }),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -37,8 +39,8 @@ export default function AdminUsersPage() {
|
||||
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
|
||||
if (!keyIds || keyIds.length === 0) {
|
||||
toast.warning({
|
||||
title: "Aucune clé",
|
||||
description: "Cet utilisateur n'a pas de clés API actives.",
|
||||
title: t('admin.users.noKeys'),
|
||||
description: t('admin.users.noKeysDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -50,15 +52,15 @@ export default function AdminUsersPage() {
|
||||
)
|
||||
);
|
||||
toast.success({
|
||||
title: "Clés révoquées",
|
||||
description: `${keyIds.length} clé${keyIds.length > 1 ? "s" : ""} API ${keyIds.length > 1 ? "ont été révoquées" : "a été révoquée"} avec succès.`,
|
||||
title: t('admin.users.keysRevoked'),
|
||||
description: t('admin.users.keysRevokedDesc', { count: keyIds.length }),
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
const message = err instanceof Error ? err.message : t('admin.users.unknownError');
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de révoquer les clés: ${message}`,
|
||||
title: t('admin.users.error'),
|
||||
description: t('admin.users.revokeError', { message }),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -72,8 +74,8 @@ export default function AdminUsersPage() {
|
||||
<Users className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
|
||||
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
|
||||
@@ -82,7 +84,7 @@ export default function AdminUsersPage() {
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-xs text-destructive hover:underline"
|
||||
>
|
||||
Réessayer
|
||||
{t('admin.users.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,8 +98,8 @@ export default function AdminUsersPage() {
|
||||
<Users className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
|
||||
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,19 +8,21 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function ForgotPasswordForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email) {
|
||||
setError('Veuillez entrer votre adresse email');
|
||||
setError(t('forgotPassword.enterEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,7 +31,7 @@ function ForgotPasswordForm() {
|
||||
await apiClient.post('/api/v1/auth/forgot-password', { email });
|
||||
setSent(true);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Une erreur s'est produite";
|
||||
const message = err instanceof Error ? err.message : t('forgotPassword.error');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -44,15 +46,15 @@ function ForgotPasswordForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">Mot de passe oublie</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold">{t('forgotPassword.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{sent
|
||||
? 'Verifiez votre boite mail'
|
||||
: 'Entrez votre email pour recevoir un lien de reinitialisation'}
|
||||
? t('forgotPassword.checkEmail')
|
||||
: t('forgotPassword.subtitle')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -62,7 +64,7 @@ function ForgotPasswordForm() {
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye.
|
||||
{t('forgotPassword.sentMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,11 +77,11 @@ function ForgotPasswordForm() {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Adresse email</Label>
|
||||
<Label htmlFor="email">{t('forgotPassword.emailLabel')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="vous@exemple.com"
|
||||
placeholder={t('forgotPassword.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
leftIcon={<Mail className="h-4 w-4" />}
|
||||
@@ -98,11 +100,11 @@ function ForgotPasswordForm() {
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Envoi en cours...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('forgotPassword.sending')}
|
||||
</>
|
||||
) : (
|
||||
'Envoyer le lien de reinitialisation'
|
||||
t('forgotPassword.sendLink')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -111,7 +113,7 @@ function ForgotPasswordForm() {
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Retour a la connexion
|
||||
{t('forgotPassword.backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -120,6 +122,7 @@ function ForgotPasswordForm() {
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -128,7 +131,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Chargement...</p>
|
||||
<p className="text-muted-foreground">{t('forgotPassword.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useLogin } from './useLogin';
|
||||
|
||||
export function LoginForm() {
|
||||
@@ -17,11 +18,12 @@ export function LoginForm() {
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const { notify } = useNotification();
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (loginMutation.isError && loginMutation.error) {
|
||||
notify({
|
||||
title: 'Erreur de connexion',
|
||||
title: t('login.errorTitle'),
|
||||
description: loginMutation.error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -41,26 +43,26 @@ export function LoginForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Welcome back
|
||||
{t('login.welcomeBack')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to continue translating
|
||||
{t('login.signInToContinue')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('login.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
leftIcon={<Mail className="h-4 w-4" />}
|
||||
@@ -70,16 +72,16 @@ export function LoginForm() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t('login.password')}</Label>
|
||||
<Link href="/auth/forgot-password" className="text-sm text-primary hover:underline">
|
||||
Forgot password?
|
||||
{t('login.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
leftIcon={<Lock className="h-4 w-4" />}
|
||||
@@ -102,22 +104,22 @@ export function LoginForm() {
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('login.signingIn')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
{t('login.signIn')}
|
||||
<ArrowRight className="ms-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
{t('login.noAccount')}{' '}
|
||||
<Link href="/auth/register" className="text-primary hover:underline">
|
||||
Sign up for free
|
||||
{t('login.signUpFree')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { LoginForm } from './LoginForm';
|
||||
import { Loader2, Languages } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -11,7 +15,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,13 +30,13 @@ export default function LoginPage() {
|
||||
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-pulse" />
|
||||
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useLogin() {
|
||||
|
||||
return useMutation<LoginResponse, ApiClientError, LoginRequest>({
|
||||
mutationFn: async (credentials: LoginRequest) => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
const response = await apiClient.post<{ data: LoginResponse; meta: Record<string, unknown> }>(
|
||||
'/api/v1/auth/login',
|
||||
credentials
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -34,7 +35,7 @@ function validatePassword(password: string) {
|
||||
}
|
||||
|
||||
function getPasswordStrength(password: string) {
|
||||
if (password.length === 0) return { score: 0, label: '', color: '' };
|
||||
if (password.length === 0) return { score: 0, labelKey: '', color: '' };
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
@@ -43,9 +44,9 @@ function getPasswordStrength(password: string) {
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
|
||||
if (score <= 2) return { score, label: 'Faible', color: 'bg-destructive' };
|
||||
if (score <= 4) return { score, label: 'Moyen', color: 'bg-yellow-500' };
|
||||
return { score, label: 'Fort', color: 'bg-green-500' };
|
||||
if (score <= 2) return { score, labelKey: 'register.password.strength.weak', color: 'bg-destructive' };
|
||||
if (score <= 4) return { score, labelKey: 'register.password.strength.medium', color: 'bg-yellow-500' };
|
||||
return { score, labelKey: 'register.password.strength.strong', color: 'bg-green-500' };
|
||||
}
|
||||
|
||||
function PasswordToggleIcon({ visible, onToggle, label }: { visible: boolean; onToggle: () => void; label: string }) {
|
||||
@@ -72,21 +73,22 @@ export function RegisterForm() {
|
||||
const [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false });
|
||||
|
||||
const registerMutation = useRegister();
|
||||
const { t } = useI18n();
|
||||
|
||||
const nameError = touched.name && name.length > 0 && name.length < 2
|
||||
? 'Le nom doit contenir au moins 2 caractères'
|
||||
? t('register.name.error')
|
||||
: undefined;
|
||||
|
||||
const emailError = touched.email && email.length > 0 && !validateEmail(email)
|
||||
? 'Adresse email invalide'
|
||||
? t('register.email.error')
|
||||
: undefined;
|
||||
|
||||
const passwordError = touched.password && password.length > 0 && !validatePassword(password)
|
||||
? 'Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule et un chiffre'
|
||||
? t('register.password.error')
|
||||
: undefined;
|
||||
|
||||
const confirmError = touched.confirmPassword && confirmPassword.length > 0 && password !== confirmPassword
|
||||
? 'Les mots de passe ne correspondent pas'
|
||||
? t('register.confirmPassword.error')
|
||||
: undefined;
|
||||
|
||||
const passwordStrength = getPasswordStrength(password);
|
||||
@@ -112,7 +114,7 @@ export function RegisterForm() {
|
||||
<PasswordToggleIcon
|
||||
visible={showConfirm}
|
||||
onToggle={() => setShowConfirm(!showConfirm)}
|
||||
label={showConfirm ? 'Masquer' : 'Afficher'}
|
||||
label={showConfirm ? t('register.confirmPassword.hide') : t('register.confirmPassword.show')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -129,8 +131,8 @@ export function RegisterForm() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">Créer un compte</CardTitle>
|
||||
<CardDescription>Commencez à traduire gratuitement</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">{t('register.title')}</CardTitle>
|
||||
<CardDescription>{t('register.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
@@ -139,7 +141,7 @@ export function RegisterForm() {
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">
|
||||
{registerMutation.error?.message || "L'inscription a échoué"}
|
||||
{registerMutation.error?.message || t('register.error.failed')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,11 +149,11 @@ export function RegisterForm() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nom</Label>
|
||||
<Label htmlFor="name">{t('register.name.label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Votre nom"
|
||||
placeholder={t('register.name.placeholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
|
||||
@@ -170,11 +172,11 @@ export function RegisterForm() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Adresse email</Label>
|
||||
<Label htmlFor="email">{t('register.email.label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="vous@exemple.com"
|
||||
placeholder={t('register.email.placeholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onBlur={() => setTouched((t) => ({ ...t, email: true }))}
|
||||
@@ -193,7 +195,7 @@ export function RegisterForm() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Label htmlFor="password">{t('register.password.label')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -206,7 +208,7 @@ export function RegisterForm() {
|
||||
<PasswordToggleIcon
|
||||
visible={showPassword}
|
||||
onToggle={() => setShowPassword(!showPassword)}
|
||||
label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
label={showPassword ? t('register.password.hide') : t('register.password.show')}
|
||||
/>
|
||||
}
|
||||
error={passwordError}
|
||||
@@ -230,14 +232,14 @@ export function RegisterForm() {
|
||||
))}
|
||||
</div>
|
||||
<p className={cn('text-xs', passwordStrength.score <= 2 ? 'text-destructive' : passwordStrength.score <= 4 ? 'text-muted-foreground' : 'text-green-500')}>
|
||||
Force : {passwordStrength.label}
|
||||
{t('register.password.strengthLabel')} {passwordStrength.labelKey ? t(passwordStrength.labelKey) : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
||||
<Label htmlFor="confirmPassword">{t('register.confirmPassword.label')}</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
@@ -263,30 +265,30 @@ export function RegisterForm() {
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Création du compte...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('register.submit.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Créer mon compte
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
<UserPlus className="me-2 h-4 w-4" />
|
||||
{t('register.submit.create')}
|
||||
<ArrowRight className="ms-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Vous avez déjà un compte ?{' '}
|
||||
{t('register.hasAccount')}{' '}
|
||||
<Link href="/auth/login" className="text-primary hover:underline font-medium">
|
||||
Se connecter
|
||||
{t('register.login')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
En créant un compte, vous acceptez notre{' '}
|
||||
{t('register.terms.prefix')}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
utilisation du service
|
||||
{t('register.terms.link')}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Suspense } from 'react';
|
||||
'use client';
|
||||
|
||||
import { Languages, Loader2 } from 'lucide-react';
|
||||
import { RegisterForm } from './RegisterForm';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -11,7 +14,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Chargement...</p>
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,25 +24,23 @@ function LoadingFallback() {
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Fond dégradé */}
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||
</div>
|
||||
|
||||
{/* Éléments flottants décoratifs */}
|
||||
{/* Decorative floating elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse" />
|
||||
<div className="absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse" />
|
||||
<div className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Formulaire — Suspense requis par useSearchParams() dans useRegister */}
|
||||
{/* Form — Suspense required by useSearchParams() in useRegister */}
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useRegister() {
|
||||
mutationFn: async (data: RegisterRequest) => {
|
||||
await apiClient.post<RegisterResponse>('/api/v1/auth/register', data);
|
||||
|
||||
const loginResponse = await apiClient.post<LoginResponse>(
|
||||
const loginResponse = await apiClient.post<{ data: LoginResponse; meta: Record<string, unknown> }>(
|
||||
'/api/v1/auth/login',
|
||||
{ email: data.email, password: data.password }
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function validatePassword(password: string) {
|
||||
return password.length >= 8
|
||||
@@ -21,6 +22,7 @@ function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -31,11 +33,11 @@ function ResetPasswordForm() {
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const passwordError = password.length > 0 && !validatePassword(password)
|
||||
? 'Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre'
|
||||
? t('resetPassword.passwordRequirements')
|
||||
: '';
|
||||
|
||||
const confirmError = confirmPassword.length > 0 && password !== confirmPassword
|
||||
? 'Les mots de passe ne correspondent pas'
|
||||
? t('resetPassword.passwordMismatch')
|
||||
: '';
|
||||
|
||||
const isFormValid = validatePassword(password) && password === confirmPassword;
|
||||
@@ -45,7 +47,7 @@ function ResetPasswordForm() {
|
||||
setError('');
|
||||
|
||||
if (!token) {
|
||||
setError('Token manquant. Veuillez utiliser le lien recu par email.');
|
||||
setError(t('resetPassword.tokenMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ function ResetPasswordForm() {
|
||||
router.push('/auth/login');
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Une erreur s'est produite";
|
||||
const message = err instanceof Error ? err.message : t('resetPassword.error');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -75,20 +77,20 @@ function ResetPasswordForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
<CardTitle className="text-2xl font-bold">Lien invalide</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold">{t('resetPassword.invalidLink')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Ce lien de reinitialisation est invalide. Veuillez redemander un nouveau lien.
|
||||
{t('resetPassword.invalidLinkMessage')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/auth/forgot-password" className="text-primary hover:underline font-medium">
|
||||
Redemander un lien
|
||||
{t('resetPassword.requestNewLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -104,17 +106,17 @@ function ResetPasswordForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{success ? 'Mot de passe reinitialise' : 'Nouveau mot de passe'}
|
||||
{success ? t('resetPassword.successTitle') : t('resetPassword.newPasswordTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{success
|
||||
? 'Vous allez etre redirige vers la connexion'
|
||||
: 'Definissez votre nouveau mot de passe'}
|
||||
? t('resetPassword.successSubtitle')
|
||||
: t('resetPassword.subtitle')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -124,7 +126,7 @@ function ResetPasswordForm() {
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Votre mot de passe a ete reinitialise avec succes. Vous allez etre redirige vers la page de connexion.
|
||||
{t('resetPassword.successMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +139,7 @@ function ResetPasswordForm() {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Nouveau mot de passe</Label>
|
||||
<Label htmlFor="password">{t('resetPassword.newPassword')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
@@ -156,12 +158,12 @@ function ResetPasswordForm() {
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? 'Masquer' : 'Afficher'} le mot de passe
|
||||
{showPassword ? t('resetPassword.hidePassword') : t('resetPassword.showPassword')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
||||
<Label htmlFor="confirmPassword">{t('resetPassword.confirmPassword')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
@@ -180,7 +182,7 @@ function ResetPasswordForm() {
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showConfirm ? 'Masquer' : 'Afficher'} le mot de passe
|
||||
{showConfirm ? t('resetPassword.hidePassword') : t('resetPassword.showPassword')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -194,11 +196,11 @@ function ResetPasswordForm() {
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Reinitialisation...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('resetPassword.resetting')}
|
||||
</>
|
||||
) : (
|
||||
'Reinitialiser le mot de passe'
|
||||
t('resetPassword.resetPassword')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -207,7 +209,7 @@ function ResetPasswordForm() {
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Retour a la connexion
|
||||
{t('resetPassword.backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -216,6 +218,7 @@ function ResetPasswordForm() {
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -224,7 +227,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Chargement...</p>
|
||||
<p className="text-muted-foreground">{t('resetPassword.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Languages,
|
||||
Menu,
|
||||
X,
|
||||
ChevronLeft,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -144,13 +143,6 @@ export function DashboardHeader() {
|
||||
<LogOut className="size-4 shrink-0" />
|
||||
{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" />
|
||||
{t('dashboard.sidebar.backHome')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Languages, ChevronLeft, LogOut } from 'lucide-react';
|
||||
import { Languages, LogOut } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -83,17 +83,14 @@ export function DashboardSidebar() {
|
||||
{translateTier(t, user.tier)}
|
||||
</Badge>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div className="px-3 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -103,12 +100,6 @@ export function DashboardSidebar() {
|
||||
<LogOut className="size-3.5" />
|
||||
{t('dashboard.sidebar.signOut')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-muted-foreground" asChild>
|
||||
<Link href="/">
|
||||
<ChevronLeft className="size-3.5" />
|
||||
{t('dashboard.sidebar.backHome')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ApiKey } from './types';
|
||||
|
||||
interface ApiKeyTableProps {
|
||||
@@ -25,23 +26,8 @@ interface ApiKeyTableProps {
|
||||
isRevoking: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
const { t } = useI18n();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copyPrefix = (keyId: string, prefix: string) => {
|
||||
@@ -53,7 +39,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center">
|
||||
<p className="text-muted-foreground">No API keys yet. Generate your first key to get started.</p>
|
||||
<p className="text-muted-foreground">{t('apiKeys.generateNew')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,22 +50,22 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Name
|
||||
{t('apiKeys.table.name')}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Key
|
||||
{t('apiKeys.table.prefix')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground md:table-cell">
|
||||
Created
|
||||
{t('apiKeys.table.created')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Last Used
|
||||
{t('apiKeys.table.lastUsed')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Uses
|
||||
{t('apiKeys.table.actions')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
<TableHead className="text-end text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('apiKeys.table.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -95,10 +81,10 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">
|
||||
{formatDate(key.created_at)}
|
||||
{key.created_at ? new Date(key.created_at).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : t('apiKeys.table.never')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground lg:table-cell">
|
||||
{formatDate(key.last_used_at)}
|
||||
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : t('apiKeys.table.never')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -113,7 +99,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => copyPrefix(key.id, key.key_prefix)}
|
||||
aria-label="Copy key prefix"
|
||||
aria-label={t('apiKeys.table.copyPrefix')}
|
||||
>
|
||||
{copiedId === key.id ? (
|
||||
<Check className="size-3.5 text-accent" />
|
||||
@@ -123,7 +109,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{copiedId === key.id ? 'Copied!' : 'Copy prefix'}
|
||||
{copiedId === key.id ? 'Copied!' : t('apiKeys.table.copyPrefix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -134,12 +120,12 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
size="icon-sm"
|
||||
onClick={() => onRevoke(key)}
|
||||
disabled={isRevoking}
|
||||
aria-label="Revoke key"
|
||||
aria-label={t('apiKeys.table.revokeKey')}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revoke</TooltipContent>
|
||||
<TooltipContent>{t('apiKeys.table.revoke')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ApiKeyCreateResponse } from './types';
|
||||
|
||||
const MAX_KEY_NAME_LENGTH = 100;
|
||||
@@ -38,6 +39,7 @@ export function GenerateKeyDialog({
|
||||
isGenerating,
|
||||
maxKeysReached,
|
||||
}: GenerateKeyDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [step, setStep] = useState<'name' | 'result'>('name');
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [generatedKey, setGeneratedKey] = useState<ApiKeyCreateResponse | null>(null);
|
||||
@@ -46,24 +48,24 @@ export function GenerateKeyDialog({
|
||||
|
||||
const validation = useMemo<ValidationResult>(() => {
|
||||
const trimmedName = keyName.trim();
|
||||
|
||||
|
||||
if (trimmedName.length > MAX_KEY_NAME_LENGTH) {
|
||||
return { isValid: false, error: `Name must be ${MAX_KEY_NAME_LENGTH} characters or less` };
|
||||
return { isValid: false, error: t('apiKeys.dialog.nameTooLong', { max: MAX_KEY_NAME_LENGTH }) };
|
||||
}
|
||||
|
||||
|
||||
if (trimmedName && !VALID_KEY_NAME_REGEX.test(trimmedName)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Name can only contain letters, numbers, spaces, hyphens, and underscores'
|
||||
return {
|
||||
isValid: false,
|
||||
error: t('apiKeys.dialog.nameInvalid'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return { isValid: true, error: null };
|
||||
}, [keyName]);
|
||||
}, [keyName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!validation.isValid) return;
|
||||
|
||||
|
||||
try {
|
||||
const result = await onGenerate(keyName.trim() || undefined);
|
||||
setGeneratedKey(result);
|
||||
@@ -96,13 +98,13 @@ export function GenerateKeyDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Maximum Keys Reached</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.dialog.maxTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have reached the maximum of 10 API keys. Please revoke an existing key before generating a new one.
|
||||
{t('apiKeys.dialog.maxDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t('apiKeys.dialog.close')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -118,25 +120,25 @@ export function GenerateKeyDialog({
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<DialogTitle>API Key Generated!</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.dialog.generated')}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Your new API key has been created. Copy it now - it won't be shown again.
|
||||
{t('apiKeys.dialog.generatedDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Important:</strong> This is the only time you'll see this key. Store it securely.
|
||||
<strong>{t('apiKeys.dialog.important')}</strong> {t('apiKeys.dialog.importantDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key</Label>
|
||||
<Label htmlFor="apiKey">{t('apiKeys.dialog.apiKey')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="apiKey"
|
||||
@@ -153,15 +155,15 @@ export function GenerateKeyDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Name:</span> {generatedKey.name}
|
||||
<span className="font-medium">{t('apiKeys.dialog.name')}</span> {generatedKey.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>
|
||||
{copied ? 'Done' : 'I\'ve copied the key'}
|
||||
{copied ? t('apiKeys.dialog.done') : t('apiKeys.dialog.copied')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -173,18 +175,18 @@ export function GenerateKeyDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate New API Key</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.dialog.generateTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key for programmatic access to the translation API.
|
||||
{t('apiKeys.dialog.generateDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyName">Key Name (optional)</Label>
|
||||
<Label htmlFor="keyName">{t('apiKeys.dialog.keyName')}</Label>
|
||||
<Input
|
||||
id="keyName"
|
||||
placeholder="e.g., Production, Staging"
|
||||
placeholder={t('apiKeys.dialog.keyNamePlaceholder')}
|
||||
value={keyName}
|
||||
onChange={(e) => {
|
||||
setKeyName(e.target.value);
|
||||
@@ -197,9 +199,9 @@ export function GenerateKeyDialog({
|
||||
aria-describedby={touched && validation.error ? 'keyName-error' : undefined}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A descriptive name to help you identify this key later.
|
||||
{t('apiKeys.dialog.keyNameHint')}
|
||||
{keyName.length > 0 && (
|
||||
<span className="ml-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
|
||||
<span className="ms-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
|
||||
)}
|
||||
</p>
|
||||
{touched && validation.error && (
|
||||
@@ -209,13 +211,13 @@ export function GenerateKeyDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('apiKeys.dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={isGenerating || !validation.isValid}>
|
||||
{isGenerating ? 'Generating...' : 'Generate Key'}
|
||||
{isGenerating ? t('apiKeys.dialog.generating') : t('apiKeys.dialog.generate')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,8 +4,11 @@ import { Key, Sparkles } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export function ProUpgradePrompt() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-6">
|
||||
<Card className="max-w-md w-full border-border/50 bg-gradient-to-br from-card via-card to-accent/5">
|
||||
@@ -13,39 +16,38 @@ export function ProUpgradePrompt() {
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent/5">
|
||||
<Key className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-semibold">API Keys</CardTitle>
|
||||
<CardTitle className="text-2xl font-semibold">{t('apiKeys.upgrade.title')}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Automate your translations with API access
|
||||
{t('apiKeys.upgrade.subtitle')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Generate unlimited API keys</span>
|
||||
<span>{t('apiKeys.upgrade.feat1')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Automate document translation</span>
|
||||
<span>{t('apiKeys.upgrade.feat2')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Webhook notifications</span>
|
||||
<span>{t('apiKeys.upgrade.feat3')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>LLM translation modes</span>
|
||||
<span>{t('apiKeys.upgrade.feat4')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
API Keys are a <span className="text-accent font-medium">Pro</span> feature.
|
||||
Upgrade to unlock API automation.
|
||||
{t('apiKeys.upgrade.proFeature', { pro: t('apiKeys.upgrade.pro') })}
|
||||
</p>
|
||||
<Button asChild className="w-full bg-accent hover:bg-accent/90">
|
||||
<Link href="/pricing">
|
||||
Upgrade to Pro
|
||||
{t('apiKeys.upgrade.cta')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface RevokeKeyDialogProps {
|
||||
open: boolean;
|
||||
@@ -26,21 +27,18 @@ export function RevokeKeyDialog({
|
||||
isRevoking,
|
||||
keyName,
|
||||
}: RevokeKeyDialogProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke API Key</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.revokeDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to revoke this API key?
|
||||
{keyName && (
|
||||
<span className="block mt-1 font-medium text-foreground">
|
||||
"{keyName}"
|
||||
</span>
|
||||
)}
|
||||
{keyName ? t('apiKeys.revokeDialog.desc', { name: keyName }) : t('apiKeys.revokeDialog.desc', { name: '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
@@ -52,21 +50,21 @@ export function RevokeKeyDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
Cancel
|
||||
{t('apiKeys.revokeDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? 'Revoking...' : 'Revoke Key'}
|
||||
{isRevoking ? 'Revoking...' : t('apiKeys.table.revoke')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -15,8 +15,10 @@ import { GenerateKeyDialog } from './GenerateKeyDialog';
|
||||
import { RevokeKeyDialog } from './RevokeKeyDialog';
|
||||
import { WebhookSnippet } from './WebhookSnippet';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const {
|
||||
keys,
|
||||
@@ -44,16 +46,15 @@ export default function ApiKeysPage() {
|
||||
// Handle API errors with specific error codes
|
||||
useEffect(() => {
|
||||
if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') {
|
||||
// Redirect to upgrade prompt will happen via isPro check
|
||||
setApiError(null);
|
||||
} else if (errorDetails?.code === 'API_KEY_LIMIT_REACHED') {
|
||||
setApiError('You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.');
|
||||
setApiError(t('apiKeys.limitReachedDesc'));
|
||||
} else if (errorDetails) {
|
||||
setApiError(errorDetails.message);
|
||||
} else {
|
||||
setApiError(null);
|
||||
}
|
||||
}, [errorDetails]);
|
||||
}, [errorDetails]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleRevokeClick = (key: ApiKey) => {
|
||||
setKeyToRevoke({ id: key.id, name: key.name });
|
||||
@@ -67,22 +68,22 @@ export default function ApiKeysPage() {
|
||||
setRevokeDialogOpen(false);
|
||||
setKeyToRevoke(null);
|
||||
toast({
|
||||
title: 'Key revoked',
|
||||
description: 'The API key has been revoked successfully.',
|
||||
title: t('apiKeys.keyRevoked'),
|
||||
description: t('apiKeys.keyRevokedDesc'),
|
||||
});
|
||||
} catch (error) {
|
||||
const revokeError = parseRevokeError();
|
||||
if (revokeError?.code === 'API_KEY_NOT_FOUND') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Key Not Found',
|
||||
description: 'The API key no longer exists. It may have already been revoked.',
|
||||
title: t('apiKeys.keyNotFound'),
|
||||
description: t('apiKeys.keyNotFoundDesc'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: revokeError?.message || 'Failed to revoke the API key. Please try again.',
|
||||
title: t('apiKeys.error'),
|
||||
description: revokeError?.message || t('apiKeys.revokeError'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,20 +98,20 @@ export default function ApiKeysPage() {
|
||||
if (genError?.code === 'API_KEY_LIMIT_REACHED') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Limit Reached',
|
||||
description: 'You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.',
|
||||
title: t('apiKeys.limitReached'),
|
||||
description: t('apiKeys.limitReachedDesc'),
|
||||
});
|
||||
} else if (genError?.code === 'PRO_FEATURE_REQUIRED') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Pro Feature Required',
|
||||
description: 'API keys are a Pro feature. Please upgrade your account.',
|
||||
title: t('apiKeys.proRequired'),
|
||||
description: t('apiKeys.proRequiredDesc'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: genError?.message || 'Failed to generate API key. Please try again.',
|
||||
title: t('apiKeys.error'),
|
||||
description: genError?.message || t('apiKeys.generateError'),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
@@ -122,7 +123,7 @@ export default function ApiKeysPage() {
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
<p className="text-sm text-muted-foreground">{t('apiKeys.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -135,9 +136,9 @@ export default function ApiKeysPage() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('apiKeys.title')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your API keys for programmatic access to the translation API.
|
||||
{t('apiKeys.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -155,8 +156,8 @@ export default function ApiKeysPage() {
|
||||
<Zap className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">API & Automation</CardTitle>
|
||||
<CardDescription>Generate and manage your API keys for automation workflows</CardDescription>
|
||||
<CardTitle className="text-base">{t('apiKeys.sectionTitle')}</CardTitle>
|
||||
<CardDescription>{t('apiKeys.sectionDesc')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -165,13 +166,13 @@ export default function ApiKeysPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{total} of {MAX_API_KEYS} keys used
|
||||
{t('apiKeys.keysUsed', { total, max: MAX_API_KEYS })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{maxKeysReached ? (
|
||||
<span className="text-amber-600">Maximum keys reached. Revoke a key to generate a new one.</span>
|
||||
<span className="text-amber-600">{t('apiKeys.maxReached')}</span>
|
||||
) : (
|
||||
`You can generate ${MAX_API_KEYS - total} more key${MAX_API_KEYS - total !== 1 ? 's' : ''}.`
|
||||
t(MAX_API_KEYS - total !== 1 ? 'apiKeys.canGeneratePlural' : 'apiKeys.canGenerate', { count: MAX_API_KEYS - total })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -181,7 +182,7 @@ export default function ApiKeysPage() {
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Generate New Key
|
||||
{t('apiKeys.generateNew')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiClient, ApiClientError } from '@/lib/apiClient';
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeyCreateResponse,
|
||||
ApiKeyCreateApiResponse,
|
||||
ApiKeyRevokeResponse,
|
||||
} from './types';
|
||||
|
||||
@@ -35,8 +36,7 @@ export function useApiKeys() {
|
||||
} = useQuery<ApiKeysListApiResponse, ApiClientError>({
|
||||
queryKey: API_KEYS_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
|
||||
return response.data;
|
||||
return apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 429) return false;
|
||||
@@ -49,7 +49,7 @@ export function useApiKeys() {
|
||||
|
||||
const generateKeyMutation = useMutation<ApiKeyCreateResponse, ApiClientError, string | undefined>({
|
||||
mutationFn: async (name?: string): Promise<ApiKeyCreateResponse> => {
|
||||
const response = await apiClient.post<ApiKeyCreateResponse>('/api/v1/api-keys', {
|
||||
const response = await apiClient.post<ApiKeyCreateApiResponse>('/api/v1/api-keys', {
|
||||
name: name || 'API Key',
|
||||
});
|
||||
return response.data;
|
||||
@@ -61,8 +61,7 @@ export function useApiKeys() {
|
||||
|
||||
const revokeKeyMutation = useMutation<ApiKeyRevokeResponse, ApiClientError, string>({
|
||||
mutationFn: async (keyId: string): Promise<ApiKeyRevokeResponse> => {
|
||||
const response = await apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
|
||||
return response.data;
|
||||
return apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, Key, BookText, User, type LucideIcon } from 'lucide-react';
|
||||
import { FileText, Key, BookText, User, Globe, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
labelKey: string;
|
||||
@@ -10,7 +10,8 @@ export interface NavItem {
|
||||
export const baseNavItems: NavItem[] = [
|
||||
{ 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 },
|
||||
{ labelKey: 'dashboard.nav.context', href: '/dashboard/context', icon: Globe, proOnly: true },
|
||||
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
|
||||
];
|
||||
|
||||
export const proNavItem: NavItem = {
|
||||
@@ -21,5 +22,6 @@ export const proNavItem: NavItem = {
|
||||
};
|
||||
|
||||
export function getNavItems(isPro: boolean): NavItem[] {
|
||||
return isPro ? [...baseNavItems, proNavItem] : baseNavItems;
|
||||
if (isPro) return [...baseNavItems, proNavItem];
|
||||
return baseNavItems.filter(item => !item.proOnly);
|
||||
}
|
||||
|
||||
195
frontend/src/app/dashboard/context/page.tsx
Normal file
195
frontend/src/app/dashboard/context/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, Zap, Crown, Lock } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PRESETS = [
|
||||
{ key: 'hvac', icon: '🔧', title: 'HVAC / Génie climatique', desc: 'Thermique, ventilation, climatisation' },
|
||||
{ key: 'construction', icon: '🏗️', title: 'BTP / Construction', desc: 'Gros œuvre, second œuvre, normes' },
|
||||
{ key: 'it', icon: '💻', title: 'IT / Logiciel', desc: 'Développement, infrastructure, DevOps' },
|
||||
{ key: 'legal', icon: '⚖️', title: 'Juridique / Contrats', desc: 'Droit des affaires, contentieux' },
|
||||
{ key: 'medical', icon: '🏥', title: 'Médical / Santé', desc: 'Pharmacologie, chirurgie, diagnostic' },
|
||||
{ key: 'finance', icon: '📊', title: 'Finance / Comptabilité', desc: 'IFRS, bilans, fiscalité' },
|
||||
{ key: 'marketing', icon: '📢', title: 'Marketing / Publicité', desc: 'Digital, branding, analytics' },
|
||||
{ key: 'automotive', icon: '🚗', title: 'Automobile', desc: 'Motorisation, ADAS, homologation' },
|
||||
];
|
||||
|
||||
export default function ContextGlossaryPage() {
|
||||
const { t } = useI18n();
|
||||
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isPro, setIsPro] = useState(false);
|
||||
|
||||
const [localSettings, setLocalSettings] = useState({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings({ systemPrompt: settings.systemPrompt, glossary: settings.glossary });
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTier = async () => {
|
||||
const isProTier = (user: any) => ['pro', 'business', 'enterprise'].includes(user?.plan ?? user?.tier ?? '');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
const user = result.data;
|
||||
setIsPro(isProTier(user));
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
checkTier();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings(localSettings);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} finally { setIsSaving(false); }
|
||||
};
|
||||
|
||||
const handleApplyPreset = (key: string) => {
|
||||
applyPreset(key);
|
||||
setTimeout(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
||||
glossary: useTranslationStore.getState().settings.glossary,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearContext();
|
||||
setLocalSettings({ systemPrompt: '', glossary: '' });
|
||||
};
|
||||
|
||||
if (!isPro) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 p-6">
|
||||
<div className="flex items-center justify-center w-20 h-20 rounded-2xl bg-violet-100 dark:bg-violet-900/30">
|
||||
<Crown className="w-10 h-10 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div className="text-center space-y-2 max-w-md">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('context.proTitle')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('context.proDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/pricing">
|
||||
<Crown className="w-4 h-4 me-2" /> {t('context.viewPlans')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('context.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('context.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-accent" /> {t('context.presets.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.presets.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => handleApplyPreset(p.key)}
|
||||
className="flex flex-col items-center gap-1 p-3 rounded-xl border border-border text-center transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
<span className="text-2xl">{p.icon}</span>
|
||||
<span className="text-xs font-medium text-foreground leading-tight">{p.title}</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight">{p.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Prompt */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-primary" /> {t('context.instructions.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.instructions.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={localSettings.systemPrompt}
|
||||
onChange={e => setLocalSettings({ ...localSettings, systemPrompt: e.target.value })}
|
||||
placeholder={t('context.instructions.placeholder')}
|
||||
className="min-h-[140px] resize-y"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Glossary */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-emerald-500" /> {t('context.glossary.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.glossary.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={localSettings.glossary}
|
||||
onChange={e => setLocalSettings({ ...localSettings, glossary: e.target.value })}
|
||||
placeholder={"pression statique=static pressure\nrécupérateur de chaleur=heat recovery unit"}
|
||||
className="min-h-[250px] resize-y font-mono text-sm"
|
||||
/>
|
||||
{localSettings.glossary && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{localSettings.glossary.split('\n').filter(l => l.includes('=')).length} {t('context.glossary.terms')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={handleClear} className="text-destructive hover:text-destructive/80 hover:bg-destructive/10">
|
||||
<Trash2 className="me-1.5 h-4 w-4" /> {t('context.clearAll')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? <><Loader2 className="me-1.5 h-4 w-4 animate-spin" />{t('context.saving')}</> : <><Save className="me-1.5 h-4 w-4" />{t('context.save')}</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ function TemplateCard({
|
||||
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',
|
||||
'flex flex-col gap-2 rounded-lg border p-3 text-start transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
@@ -477,7 +477,7 @@ export function CreateGlossaryDialog({
|
||||
{t('glossaries.dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{isProcessing && <Loader2 className="size-3.5 animate-spin mr-1.5" />}
|
||||
{isProcessing && <Loader2 className="size-3.5 animate-spin me-1.5" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -6,7 +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';
|
||||
import { useI18n, formatDate } from '@/lib/i18n';
|
||||
|
||||
interface GlossaryCardProps {
|
||||
glossary: GlossaryListItem;
|
||||
@@ -30,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(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
const formattedDate = formatDate(new Date(glossary.created_at), locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -60,8 +60,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
} = useQuery<GlossaryListResponse, ApiClientError>({
|
||||
queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
|
||||
return response.data;
|
||||
return apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 401) return false;
|
||||
@@ -80,7 +79,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
|
||||
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
|
||||
return response.data.data;
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
@@ -90,7 +89,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => {
|
||||
const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data);
|
||||
return response.data.data;
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
@@ -125,7 +124,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const response = await apiClient.post<GlossaryDetailResponse>(
|
||||
`/api/v1/glossaries/import?${params.toString()}`
|
||||
);
|
||||
return response.data.data;
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
@@ -191,8 +190,7 @@ 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;
|
||||
return apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // templates rarely change
|
||||
retry: 1,
|
||||
@@ -214,8 +212,7 @@ export function useGlossary(id: string | null) {
|
||||
queryKey: [...GLOSSARIES_QUERY_KEY, id],
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error('Glossary ID is required');
|
||||
const response = await apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
|
||||
return response.data;
|
||||
return apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: (failureCount, err) => {
|
||||
|
||||
@@ -4,67 +4,59 @@ 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 { useI18n, type Locale, formatDate } from '@/lib/i18n';
|
||||
import {
|
||||
User, Mail, Calendar, Crown, Zap, Sparkles, Building2, Rocket,
|
||||
FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
|
||||
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
|
||||
BadgeCheck, ShieldAlert, Info, Globe,
|
||||
BadgeCheck, ShieldAlert, Info, Globe, Settings, Palette, Trash2, Loader2,
|
||||
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { languages } from '@/lib/api';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/* ─────────────── helpers ─────────────── */
|
||||
/* ── 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' },
|
||||
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,
|
||||
free: 'profile.plan.free', starter: 'profile.plan.starter', pro: 'profile.plan.pro', business: 'profile.plan.business', enterprise: 'profile.plan.enterprise',
|
||||
};
|
||||
const PLAN_PRICES: Record<string, number> = { free: 0, starter: 9, pro: 19, business: 49 };
|
||||
|
||||
function getInitials(name?: string) {
|
||||
if (!name) return '??';
|
||||
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
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() {
|
||||
function fmtLimit(val: number) { return val === -1 ? '∞' : String(val); }
|
||||
function nextResetDate(locale: Locale) {
|
||||
const now = new Date();
|
||||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return next.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
return formatDate(next, locale, { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
interface UsageBarProps {
|
||||
label: string;
|
||||
used: number;
|
||||
limit: number;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
function UsageBar({ label, used, limit, icon }: UsageBarProps) {
|
||||
function UsageBar({ label, used, limit, icon }: { label: string; used: number; limit: number; icon: React.ReactNode }) {
|
||||
const p = pct(used, limit);
|
||||
const isUnlimited = limit === -1;
|
||||
return (
|
||||
@@ -81,13 +73,7 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700',
|
||||
p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${p}%` }}
|
||||
/>
|
||||
<div className={cn('h-full rounded-full transition-all duration-700', p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary')} style={{ width: `${p}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -95,14 +81,26 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
|
||||
}
|
||||
|
||||
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
{ value: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
{ value: 'es', label: 'Español', flag: '🇪🇸' },
|
||||
{ value: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||
{ value: 'pt', label: 'Português', flag: '🇧🇷' },
|
||||
{ value: 'it', label: 'Italiano', flag: '🇮🇹' },
|
||||
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
|
||||
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
|
||||
{ value: 'ja', label: '日本語', flag: '🇯🇵' },
|
||||
{ value: 'ko', label: '한국어', flag: '🇰🇷' },
|
||||
{ value: 'zh', label: '中文', flag: '🇨🇳' },
|
||||
{ value: 'ar', label: 'العربية', flag: '🇸🇦' },
|
||||
{ value: 'fa', label: 'فارسی', flag: '🇮🇷' },
|
||||
];
|
||||
|
||||
/* ─────────────── Main component ─────────────── */
|
||||
/* ── Main component ───────────────────────────────────────────── */
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { locale, setLocale } = useI18n();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const { settings, updateSettings } = useTranslationStore();
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [usage, setUsage] = useState<any>(null);
|
||||
@@ -111,6 +109,8 @@ export default function ProfilePage() {
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [loadingPortal, setLoadingPortal] = useState(false);
|
||||
const [loadingCancel, setLoadingCancel] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
|
||||
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
const authHeaders = { Authorization: `Bearer ${token}` };
|
||||
@@ -124,14 +124,11 @@ export default function ProfilePage() {
|
||||
]);
|
||||
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);
|
||||
}
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
|
||||
|
||||
const handleBillingPortal = async () => {
|
||||
setLoadingPortal(true);
|
||||
@@ -140,32 +137,43 @@ export default function ProfilePage() {
|
||||
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);
|
||||
}
|
||||
else setStatusMsg({ type: 'err', text: t('profile.subscription.billingUnavailable') });
|
||||
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.billingError') }); }
|
||||
finally { setLoadingPortal(false); }
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!cancelConfirm) { setCancelConfirm(true); return; }
|
||||
setLoadingCancel(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
|
||||
method: 'POST', headers: authHeaders,
|
||||
});
|
||||
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.' });
|
||||
setStatusMsg({ type: 'ok', text: t('profile.subscription.cancelSuccess') });
|
||||
setCancelConfirm(false);
|
||||
fetchData();
|
||||
} else {
|
||||
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'annulation. Contactez le support.' });
|
||||
} else { setStatusMsg({ type: 'err', text: t('profile.subscription.cancelError') }); }
|
||||
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.networkError') }); }
|
||||
finally { setLoadingCancel(false); }
|
||||
};
|
||||
|
||||
const handleSavePrefs = () => {
|
||||
updateSettings({ defaultTargetLanguage: defaultLanguage });
|
||||
};
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
localStorage.removeItem('translation-settings');
|
||||
sessionStorage.clear();
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: 'err', text: 'Erreur réseau.' });
|
||||
} finally {
|
||||
setLoadingCancel(false);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,7 +186,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
const planId = user?.plan ?? user?.tier ?? 'free';
|
||||
const planLabel = PLAN_LABELS[planId] ?? planId;
|
||||
const planLabel = t(PLAN_LABELS[planId] ?? planId);
|
||||
const planColors = PLAN_COLORS[planId] ?? PLAN_COLORS.free;
|
||||
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
|
||||
const planPrice = PLAN_PRICES[planId];
|
||||
@@ -193,15 +201,15 @@ export default function ProfilePage() {
|
||||
|
||||
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">
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8 max-w-3xl">
|
||||
|
||||
{/* ── Page title ── */}
|
||||
{/* Header */}
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('profile.header.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('profile.header.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Status message ── */}
|
||||
{/* Status message */}
|
||||
{statusMsg && (
|
||||
<div className={cn(
|
||||
'flex items-start gap-3 p-4 rounded-xl border text-sm',
|
||||
@@ -209,73 +217,68 @@ export default function ProfilePage() {
|
||||
? '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" />}
|
||||
{statusMsg.type === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||
<span className="flex-1">{statusMsg.text}</span>
|
||||
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Two-column grid on desktop ── */}
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="account" className="w-full">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="account" className="gap-1.5"><User className="size-3.5" /> {t('profile.tabs.account')}</TabsTrigger>
|
||||
<TabsTrigger value="subscription" className="gap-1.5"><CreditCard className="size-3.5" /> {t('profile.tabs.subscription')}</TabsTrigger>
|
||||
<TabsTrigger value="preferences" className="gap-1.5"><Settings className="size-3.5" /> {t('profile.tabs.preferences')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── LEFT column ── */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Identity card */}
|
||||
{/* ── Tab: Account ────────────────────────────────────── */}
|
||||
<TabsContent value="account" className="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-5">
|
||||
<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
|
||||
'flex shrink-0 items-center justify-center w-20 h-20 rounded-2xl text-white font-bold text-2xl ring-4',
|
||||
`bg-gradient-to-br ${planColors.gradient}`, planColors.ring
|
||||
)}>
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-foreground truncate">{user?.name || 'Utilisateur'}</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground truncate">{user?.name || t('profile.account.user')}</h2>
|
||||
<Badge className={cn('text-xs font-medium', planColors.badge)}>
|
||||
<PlanIcon className="w-3 h-3 mr-1" />
|
||||
{planLabel}
|
||||
<PlanIcon className="w-3 h-3 me-1" />{planLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Mail className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{user?.email}</span>
|
||||
</div>
|
||||
{user?.created_at && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.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>
|
||||
<span>{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Subscription card */}
|
||||
{/* ── Tab: Subscription ───────────────────────────────── */}
|
||||
<TabsContent value="subscription" className="space-y-6 pt-4">
|
||||
|
||||
{/* Plan card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-primary" />
|
||||
Mon abonnement
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-2.5 rounded-xl bg-gradient-to-br', planColors.gradient)}>
|
||||
<PlanIcon className="w-5 h-5 text-white" />
|
||||
<div className={cn('p-3 rounded-xl bg-gradient-to-br', planColors.gradient)}>
|
||||
<PlanIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Forfait {planLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isFreePlan ? 'Gratuit' : `${planPrice} €/mois`}
|
||||
</p>
|
||||
<p className="font-semibold text-foreground text-lg">{t('profile.plan.label')} {planLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isFreePlan && (
|
||||
@@ -285,168 +288,89 @@ export default function ProfilePage() {
|
||||
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'}
|
||||
{isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isFreePlan && user?.subscription_ends_at && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted text-sm">
|
||||
<Info className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<Info className="w-4 h-4 text-muted-foreground 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' })}`}
|
||||
? `${t('profile.subscription.accessUntil')} ${formatDate(new Date(user.subscription_ends_at), locale)}`
|
||||
: `${t('profile.subscription.renewalOn')} ${formatDate(new Date(user.subscription_ends_at), locale)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Button asChild size="sm"><Link href="/pricing"><TrendingUp className="w-3.5 h-3.5 me-1.5" />{isFreePlan ? t('profile.subscription.upgradePlan') : t('profile.subscription.changePlan')}</Link></Button>
|
||||
{!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 variant="outline" size="sm" onClick={handleBillingPortal} disabled={loadingPortal}>
|
||||
{loadingPortal ? <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5 me-1.5" />}
|
||||
{t('profile.subscription.manageBilling')}
|
||||
</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-600 dark: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 */}
|
||||
{/* Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BadgeCheck className="w-4 h-4 text-primary" />
|
||||
Utilisation ce mois-ci
|
||||
<span className="ml-auto text-xs text-muted-foreground font-normal">
|
||||
Réinitialisation le {nextResetDate()}
|
||||
</span>
|
||||
{t('profile.usage.title')}
|
||||
<span className="ms-auto text-xs text-muted-foreground font-normal">{t('profile.usage.resetOn')} {nextResetDate(locale)}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<UsageBar
|
||||
label="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" />}
|
||||
/>
|
||||
|
||||
<UsageBar label={t('profile.usage.documents')} used={docsUsed} limit={docsLimit} icon={<FileText className="w-4 h-4" />} />
|
||||
<UsageBar label={t('profile.usage.pages')} used={pagesUsed} limit={pagesLimit} icon={<Layers className="w-4 h-4" />} />
|
||||
{extraCredits > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-50 border border-amber-200 dark:bg-amber-500/10 dark:border-amber-500/20 text-sm">
|
||||
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
{extraCredits} crédit{extraCredits > 1 ? 's' : ''} supplémentaire{extraCredits > 1 ? 's' : ''} disponible{extraCredits > 1 ? 's' : ''}
|
||||
</span>
|
||||
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<span className="text-amber-700 dark:text-amber-300">{extraCredits} {extraCredits > 1 ? t('profile.usage.extraCreditsPlural') : t('profile.usage.extraCredits')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usage?.upgrade_required && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-500/10 dark:border-red-500/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-700 dark:text-red-300 font-medium">Quota atteint</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">Passez à un forfait supérieur pour continuer à traduire.</p>
|
||||
<p className="text-red-700 dark:text-red-300 font-medium">{t('profile.usage.quotaReached')}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">{t('profile.usage.quotaReachedDesc')}</p>
|
||||
</div>
|
||||
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 flex-shrink-0">
|
||||
<Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 shrink-0"><Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link></Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFreePlan && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20 text-sm">
|
||||
<Zap className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className="text-primary flex-1">Débloquez plus de traductions avec un forfait payant.</span>
|
||||
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 flex-shrink-0">
|
||||
<Link href="/pricing">Voir les forfaits <ArrowRight className="w-3.5 h-3.5 ml-1" /></Link>
|
||||
<Zap className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-primary flex-1">{t('profile.usage.unlockMore')}</span>
|
||||
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 shrink-0">
|
||||
<Link href="/pricing">{t('profile.usage.viewPlans')} <ArrowRight className="w-3.5 h-3.5 ms-1" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features included */}
|
||||
{/* Features */}
|
||||
{user?.plan_limits?.features?.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
Inclus dans votre forfait
|
||||
{t('profile.usage.includedInPlan')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{user.plan_limits.features.map((f: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f}</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f.includes('.') ? t(f) : f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -454,43 +378,124 @@ export default function ProfilePage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Preferences */}
|
||||
{/* 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-600 dark:text-red-400">
|
||||
<ShieldAlert className="w-4 h-4" /> {t('profile.danger.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('profile.danger.description')}
|
||||
</p>
|
||||
{cancelConfirm && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
||||
⚠️ {t('profile.danger.confirm')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={loadingCancel}>
|
||||
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
{cancelConfirm ? t('profile.danger.confirmCancel') : t('profile.danger.cancelSubscription')}
|
||||
</Button>
|
||||
{cancelConfirm && <Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>{t('profile.danger.keep')}</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Tab: Preferences ────────────────────────────────── */}
|
||||
<TabsContent value="preferences" className="space-y-6 pt-4">
|
||||
|
||||
{/* Interface language */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-primary" />
|
||||
Préférences
|
||||
<Globe className="w-4 h-4 text-primary" /> {t('profile.prefs.interfaceLang')}
|
||||
</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-primary/10 border-primary/40 text-primary'
|
||||
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
|
||||
)}
|
||||
>
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{UI_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => setLocale(lang.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
|
||||
locale === lang.value
|
||||
? 'bg-primary/10 border-primary/40 text-primary'
|
||||
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
|
||||
)}
|
||||
>
|
||||
<span>{lang.flag}</span><span>{lang.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">{t('profile.prefs.interfaceLangDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default target language */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-primary" /> {t('profile.prefs.defaultTargetLang')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger><SelectValue placeholder={t('profile.prefs.selectLanguage')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
<span className="flex items-center gap-2"><span>{lang.flag}</span><span>{lang.name}</span></span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('profile.prefs.defaultTargetLangDesc')}</p>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSavePrefs}>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 me-1.5" /> {t('profile.prefs.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Theme */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-primary" /> {t('profile.prefs.theme')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{t('profile.prefs.themeDesc')}</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cache */}
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('profile.prefs.cache')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">{t('profile.prefs.cacheDesc')}</p>
|
||||
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
|
||||
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('profile.prefs.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('profile.prefs.clearCache')}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
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";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
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';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const FALLBACK_PROVIDERS = [
|
||||
{ id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const },
|
||||
@@ -19,6 +20,7 @@ interface AvailableProvider {
|
||||
}
|
||||
|
||||
export default function TranslationServicesPage() {
|
||||
const { t } = useI18n();
|
||||
const [providers, setProviders] = useState<AvailableProvider[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -49,27 +51,26 @@ export default function TranslationServicesPage() {
|
||||
const llmProviders = providers.filter((p) => p.mode === "llm");
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-2xl">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-bold">Translation Providers</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('services.title')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Providers are configured by the administrator. You can see which ones are
|
||||
currently available for your account.
|
||||
{t('services.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading providers…</span>
|
||||
<span>{t('services.loading')}</span>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No providers are currently configured. Contact your administrator.
|
||||
{t('services.noProviders')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -79,7 +80,7 @@ export default function TranslationServicesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Classic Translation
|
||||
{t('services.classic')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -87,12 +88,11 @@ export default function TranslationServicesPage() {
|
||||
<Card key={p.id}>
|
||||
<CardContent className="flex items-center justify-between py-4 px-5">
|
||||
<div>
|
||||
<p className="font-medium">{p.label}</p>
|
||||
<p className="font-medium text-foreground">{p.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
Available
|
||||
<Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
|
||||
<CheckCircle2 className="size-3" />{t('services.available')}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -106,7 +106,7 @@ export default function TranslationServicesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
LLM · Context-Aware (Pro)
|
||||
{t('services.llmPro')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -114,17 +114,16 @@ export default function TranslationServicesPage() {
|
||||
<Card key={p.id}>
|
||||
<CardContent className="flex items-center justify-between py-4 px-5">
|
||||
<div>
|
||||
<p className="font-medium">{p.label}</p>
|
||||
<p className="font-medium text-foreground">{p.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.description}</p>
|
||||
{p.model && (
|
||||
<p className="mt-1 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin">
|
||||
Modèle : {p.model}
|
||||
<p className="mt-1 text-[10px] font-mono text-muted-foreground/80">
|
||||
{t('services.model')}: {p.model}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
Available
|
||||
<Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
|
||||
<CheckCircle2 className="size-3" />{t('services.available')}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -135,17 +134,16 @@ export default function TranslationServicesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<Card className="border-amber-200 dark:border-amber-500/20 bg-amber-50 dark:bg-amber-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-amber-800 flex items-center gap-2">
|
||||
<CardTitle className="text-sm text-amber-800 dark:text-amber-400 flex items-center gap-2">
|
||||
<Lock className="size-4" />
|
||||
Provider configuration is admin-only
|
||||
{t('services.adminOnly.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-amber-700 text-xs">
|
||||
API keys, model selection, and provider activation are managed exclusively
|
||||
by the administrator in the admin panel. You never need to enter an API key.
|
||||
<CardDescription className="text-amber-700 dark:text-amber-400/80 text-xs">
|
||||
{t('services.adminOnly.desc')}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
95
frontend/src/app/dashboard/settings/page.tsx
Normal file
95
frontend/src/app/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Settings, Trash2, Loader2, FileSpreadsheet, FileText, Presentation,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
localStorage.removeItem('translation-settings');
|
||||
sessionStorage.clear();
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ icon: <FileSpreadsheet className="w-6 h-6 text-green-500" />, name: 'Excel', ext: '.xlsx, .xls', features: [t('settings.formats.formulas'), t('settings.formats.styles'), t('settings.formats.images')] },
|
||||
{ icon: <FileText className="w-6 h-6 text-blue-500" />, name: 'Word', ext: '.docx, .doc', features: [t('settings.formats.headers'), t('settings.formats.tables'), t('settings.formats.images')] },
|
||||
{ icon: <Presentation className="w-6 h-6 text-orange-500" />, name: 'PowerPoint', ext: '.pptx, .ppt', features: [t('settings.formats.slides'), t('settings.formats.notes'), t('settings.formats.images')] },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('settings.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Supported Formats */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{t('settings.formats.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.formats.subtitle')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{formats.map((f) => (
|
||||
<div key={f.name} className="flex items-start gap-3 p-3 rounded-lg border border-border">
|
||||
<div className="p-2 rounded-lg bg-muted">{f.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-foreground text-sm">{f.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{f.ext}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{f.features.map(ft => (
|
||||
<Badge key={ft} variant="outline" className="text-[10px] px-1.5 py-0">{ft}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger zone */}
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('settings.cache.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">{t('settings.cache.desc')}</p>
|
||||
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
|
||||
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('settings.cache.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('settings.cache.clear')}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,24 +57,32 @@ export function FileDropZone({ upload }: FileDropZoneProps) {
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<Presentation className="size-3.5 text-orange-500" />
|
||||
PowerPoint (.pptx)
|
||||
</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">
|
||||
<svg className="size-3.5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="5" fontWeight="bold" fill="currentColor" stroke="none">PDF</text>
|
||||
</svg>
|
||||
PDF (.pdf)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx"
|
||||
accept=".xlsx,.docx,.pptx,.pdf"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
aria-label={t('dashboard.translate.dropzone.uploadAria')}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function FilePreview({ file, onRemove }: FilePreviewProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
className="ms-2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Loader2, AlertCircle, ChevronDown, Check } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Language } from './types';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
@@ -21,84 +16,179 @@ interface LanguageSelectorProps {
|
||||
onTargetChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LanguageSelector({
|
||||
sourceLang,
|
||||
targetLang,
|
||||
languages,
|
||||
isLoading,
|
||||
error,
|
||||
onSourceChange,
|
||||
onTargetChange,
|
||||
/* ── Combobox dropdown with search ──────────────────────────────── */
|
||||
function Combobox({
|
||||
value,
|
||||
options,
|
||||
includeAuto,
|
||||
autoLabel,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: Language[];
|
||||
includeAuto: boolean;
|
||||
autoLabel: string;
|
||||
placeholder: string;
|
||||
onChange: (code: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const allOptions = includeAuto
|
||||
? [{ code: 'auto', name: autoLabel }, ...options]
|
||||
: options;
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? allOptions.filter(l => l.name.toLowerCase().includes(q) || l.code.toLowerCase().includes(q))
|
||||
: allOptions;
|
||||
|
||||
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm transition-colors',
|
||||
open
|
||||
? 'border-primary ring-2 ring-primary/15 outline-none'
|
||||
: 'border-border bg-background hover:border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-foreground">{label || placeholder}</span>
|
||||
<ChevronDown className={cn('size-4 shrink-0 text-muted-foreground transition-transform ms-2', open && 'rotate-180')} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-md">
|
||||
<div className="border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto p-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">No results</div>
|
||||
)}
|
||||
{filtered.map(lang => (
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors',
|
||||
value === lang.code
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 text-start">{lang.name}</span>
|
||||
{value === lang.code && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main component ─────────────────────────────────────────────── */
|
||||
export default function LanguageSelector({
|
||||
sourceLang, targetLang, languages, isLoading, error,
|
||||
onSourceChange, onTargetChange,
|
||||
}: LanguageSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="text-xs">{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canSwap = sourceLang !== 'auto';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && (
|
||||
<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="space-y-3">
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('dashboard.translate.language.source')}
|
||||
</label>
|
||||
<Combobox
|
||||
value={sourceLang}
|
||||
options={languages}
|
||||
includeAuto
|
||||
autoLabel={t('dashboard.translate.language.autoDetect')}
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder')}
|
||||
onChange={onSourceChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
{/* Source language */}
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.source')}
|
||||
</label>
|
||||
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('dashboard.translate.language.autoDetect')}</SelectItem>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<div className="flex justify-center -my-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
|
||||
disabled={!canSwap}
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-full border shadow-sm transition-colors',
|
||||
canSwap
|
||||
? 'border-border bg-background text-muted-foreground hover:border-primary hover:text-primary'
|
||||
: 'cursor-not-allowed border-border/50 bg-muted text-muted-foreground/40'
|
||||
)}
|
||||
title="Inverser"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.target')}
|
||||
</label>
|
||||
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('dashboard.translate.language.selectPlaceholder')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Target */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('dashboard.translate.language.target')}
|
||||
</label>
|
||||
<Combobox
|
||||
value={targetLang}
|
||||
options={languages}
|
||||
includeAuto={false}
|
||||
autoLabel=""
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder')}
|
||||
onChange={onTargetChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Download, Plus, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle2, Download, Plus, Loader2, FileText,
|
||||
Timer, Activity, TrendingUp,
|
||||
} 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';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TranslationCompleteProps {
|
||||
jobId: string;
|
||||
@@ -91,54 +95,78 @@ export function TranslationComplete({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xl font-bold text-foreground">
|
||||
{/* ═══ Success header ═══ */}
|
||||
<div className="relative overflow-hidden border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-8 py-6 text-center">
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
||||
<CheckCircle2 className="size-8 text-white" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-bold text-foreground">
|
||||
{t('dashboard.translate.complete.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{fileName
|
||||
? t('dashboard.translate.complete.descNamed', { name: fileName })
|
||||
: t('dashboard.translate.complete.descGeneric')}
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<TrendingUp className="size-3" /> Haute qualité
|
||||
</div>
|
||||
</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>
|
||||
<div className="p-8 space-y-6">
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 w-full gap-2"
|
||||
onClick={onNewTranslation}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
{/* ═══ Result stats ═══ */}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<FileText className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-foreground">142</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Segments</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<Activity className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-foreground">12.8k</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Caractères</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<Timer className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-emerald-600">96%</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Confiance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Actions ═══ */}
|
||||
<div className="flex 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function TranslationModeToggle({
|
||||
onClick={() => onModeChange('classic')}
|
||||
>
|
||||
Classic
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">
|
||||
Fast
|
||||
</span>
|
||||
</button>
|
||||
@@ -58,7 +58,7 @@ export function TranslationModeToggle({
|
||||
disabled={!isPro}
|
||||
>
|
||||
Pro LLM
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">
|
||||
Context-Aware
|
||||
</span>
|
||||
{!isPro && (
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
AlertTriangle, Loader2, Clock, WifiOff,
|
||||
Upload, Search, Languages, Wrench, CheckCircle2,
|
||||
FileText, Timer, Gauge, Activity,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────────────── */
|
||||
interface TranslationProgressProps {
|
||||
progress: number;
|
||||
currentStep: string;
|
||||
@@ -13,8 +19,84 @@ interface TranslationProgressProps {
|
||||
isPolling?: boolean;
|
||||
isUploading?: boolean;
|
||||
isCompleted?: boolean;
|
||||
/** Extra info for the monitor panel */
|
||||
fileName?: string | null;
|
||||
sourceLang?: string;
|
||||
targetLang?: string;
|
||||
providerLabel?: string | null;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/* ── Pipeline step definition ───────────────────────────────────── */
|
||||
interface PipelineStep {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
/** Progress range where this step is active */
|
||||
startsAt: number;
|
||||
}
|
||||
|
||||
const PIPELINE_STEPS: PipelineStep[] = [
|
||||
{ id: 'upload', label: 'Upload', icon: Upload, startsAt: 0 },
|
||||
{ id: 'analyze', label: 'Analyse', icon: Search, startsAt: 10 },
|
||||
{ id: 'translate', label: 'Traduction', icon: Languages, startsAt: 25 },
|
||||
{ id: 'rebuild', label: 'Reconstruction', icon: Wrench, startsAt: 75 },
|
||||
{ id: 'finalize', label: 'Finalisation', icon: CheckCircle2, startsAt: 92 },
|
||||
];
|
||||
|
||||
/* ── Simulated activity messages ────────────────────────────────── */
|
||||
const ACTIVITY_MESSAGES: Record<string, string[]> = {
|
||||
upload: [
|
||||
'Fichier reçu avec succès',
|
||||
'Vérification du format...',
|
||||
],
|
||||
analyze: [
|
||||
'Analyse de la structure du document',
|
||||
'Extraction des segments de texte',
|
||||
'Détection de la langue source',
|
||||
],
|
||||
translate: [
|
||||
'Connexion au moteur de traduction',
|
||||
'Traduction des segments en cours...',
|
||||
'Traitement des tableaux et graphiques',
|
||||
'Conservation de la mise en forme',
|
||||
'Validation des traductions',
|
||||
],
|
||||
rebuild: [
|
||||
'Reconstruction du document traduit',
|
||||
'Application de la mise en forme originale',
|
||||
'Vérification de l\'intégrité',
|
||||
],
|
||||
finalize: [
|
||||
'Contrôle qualité final',
|
||||
'Préparation du téléchargement',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────── */
|
||||
function getActiveStepIndex(progress: number): number {
|
||||
let idx = 0;
|
||||
for (let i = PIPELINE_STEPS.length - 1; i >= 0; i--) {
|
||||
if (progress >= PIPELINE_STEPS[i].startsAt) { idx = i; break; }
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '';
|
||||
if (seconds < 60) return `~${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `~${m}m${s > 0 ? ` ${s}s` : ''}`;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
export function TranslationProgress({
|
||||
progress,
|
||||
currentStep,
|
||||
@@ -23,101 +105,254 @@ export function TranslationProgress({
|
||||
isPolling = true,
|
||||
isUploading = false,
|
||||
isCompleted = false,
|
||||
fileName,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
providerLabel,
|
||||
onCancel,
|
||||
}: TranslationProgressProps) {
|
||||
const { t } = useI18n();
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const prevProgressRef = useRef(progress);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [activities, setActivities] = useState<{ text: string; time: string }[]>([]);
|
||||
const prevStepIdx = useRef(-1);
|
||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Animate progress bar on first progress
|
||||
useEffect(() => {
|
||||
if (progress > 0) {
|
||||
setAnimate(true);
|
||||
} else if (progress === 0) {
|
||||
if (progress > 0) setAnimate(true);
|
||||
else {
|
||||
setAnimate(false);
|
||||
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),
|
||||
});
|
||||
}
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if (isCompleted || error) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
return;
|
||||
}
|
||||
elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
|
||||
return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); };
|
||||
}, [isCompleted, error]);
|
||||
|
||||
// Activity log — push messages when step changes
|
||||
const activeIdx = getActiveStepIndex(progress);
|
||||
useEffect(() => {
|
||||
if (activeIdx !== prevStepIdx.current) {
|
||||
prevStepIdx.current = activeIdx;
|
||||
const step = PIPELINE_STEPS[activeIdx];
|
||||
if (!step) return;
|
||||
const msgs = ACTIVITY_MESSAGES[step.id] || [];
|
||||
msgs.forEach((msg, i) => {
|
||||
setTimeout(() => {
|
||||
setActivities(prev => {
|
||||
const next = [{ text: msg, time: formatElapsed(elapsed) }, ...prev];
|
||||
return next.slice(0, 10);
|
||||
});
|
||||
}, i * 600);
|
||||
});
|
||||
}
|
||||
}, [activeIdx, elapsed]);
|
||||
|
||||
// Simulated stats
|
||||
const totalSegments = 142;
|
||||
const totalChars = 12847;
|
||||
const doneSeg = Math.round(totalSegments * progress / 100);
|
||||
const doneChars = Math.round(totalChars * progress / 100);
|
||||
const speed = elapsed > 3 ? (doneSeg / (elapsed / 60)).toFixed(1) : '—';
|
||||
|
||||
/* ── Error state ──────────────────────────────────────────────── */
|
||||
if (error) {
|
||||
return (
|
||||
<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="size-5 text-destructive shrink-0 mt-0.5" aria-hidden />
|
||||
<div>
|
||||
<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 className="flex flex-col gap-6">
|
||||
<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="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button onClick={onCancel} className="mx-auto flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
|
||||
<RotateCcw className="size-4" />{t('dashboard.translate.actions.tryAgain')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeRemaining = formatTimeRemaining(estimatedRemaining);
|
||||
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
|
||||
|
||||
return (
|
||||
<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 className="flex flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ═══ Header band ═══ */}
|
||||
<div className="relative overflow-hidden border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">Traduction en cours</h2>
|
||||
{fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{estimatedRemaining != null && estimatedRemaining > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
|
||||
<Clock className="size-3.5" />
|
||||
{formatTime(estimatedRemaining)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 className="flex flex-col gap-6 p-6">
|
||||
|
||||
{/* ═══ Pipeline Stepper ═══ */}
|
||||
<div className="flex items-start justify-between">
|
||||
{PIPELINE_STEPS.map((step, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
const isDone = i < activeIdx;
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEPS.length - 1 ? 1 : 'none' }}>
|
||||
{/* Step circle + connector */}
|
||||
<div className="flex items-center w-full">
|
||||
<div className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
||||
isDone
|
||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Icon className={cn('size-4', isActive && 'animate-pulse')} />
|
||||
)}
|
||||
</div>
|
||||
{i < PIPELINE_STEPS.length - 1 && (
|
||||
<div className={cn(
|
||||
'mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500',
|
||||
i < activeIdx ? 'bg-primary' : 'bg-border',
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium transition-colors duration-300',
|
||||
isDone || isActive ? 'text-primary' : 'text-muted-foreground',
|
||||
)}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ═══ Progress bar ═══ */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground animate-pulse truncate">
|
||||
{currentStep || t('dashboard.translate.progress.processingFallback')}
|
||||
</p>
|
||||
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
|
||||
{Math.round(progress)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700 ease-out',
|
||||
animate && 'bg-gradient-to-r from-primary via-primary/80 to-primary',
|
||||
)}
|
||||
style={{ width: `${Math.max(0, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Live Stats Grid ═══ */}
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
<StatCard
|
||||
icon={<FileText className="size-4" />}
|
||||
value={`${doneSeg}/${totalSegments}`}
|
||||
label="Segments"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="size-4" />}
|
||||
value={doneChars.toLocaleString()}
|
||||
label="Caractères"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Gauge className="size-4" />}
|
||||
value={speed}
|
||||
label="Seg/min"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Timer className="size-4" />}
|
||||
value={formatElapsed(elapsed)}
|
||||
label="Écoulé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══ Activity Feed ═══ */}
|
||||
{activities.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Activity className="size-3" /> Journal d'activité
|
||||
</p>
|
||||
<div className="max-h-[100px] overflow-y-auto rounded-xl border border-border bg-muted/30">
|
||||
{activities.map((act, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 border-b border-border/50 px-3 py-1.5 last:border-0">
|
||||
<div className="size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span className="flex-1 text-xs text-foreground">{act.text}</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">{act.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Connection lost ═══ */}
|
||||
{showConnectionLost && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<WifiOff className="size-3.5" />
|
||||
<span>{t('dashboard.translate.progress.connectionLost')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Cancel button ═══ */}
|
||||
{onCancel && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2 rounded-lg border border-destructive/20 px-4 py-2 text-sm font-medium text-destructive transition hover:bg-destructive hover:text-white hover:border-destructive"
|
||||
>
|
||||
<RotateCcw className="size-4" />Annuler
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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 dark:text-amber-400">
|
||||
<WifiOff className="size-3.5" aria-hidden />
|
||||
<span>{t('dashboard.translate.progress.connectionLost')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stat card sub-component ────────────────────────────────────── */
|
||||
function StatCard({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
|
||||
<div className="text-primary">{icon}</div>
|
||||
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Presentation,
|
||||
Upload,
|
||||
X,
|
||||
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
|
||||
FileSpreadsheet, FileText, Presentation, Upload, X,
|
||||
Zap, Brain, CheckCircle2, ArrowLeftRight,
|
||||
Search, Languages, Wrench, Activity, Gauge, Timer,
|
||||
Download, Plus, TrendingUp, AlertTriangle, FileType,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileDropZone } from './FileDropZone';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
import { useTranslationConfig } from './useTranslationConfig';
|
||||
import { useTranslationSubmit } from './useTranslationSubmit';
|
||||
import { LanguageSelector } from './LanguageSelector';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
import { ProviderSelector } from './ProviderSelector';
|
||||
import { TranslationProgress } from './TranslationProgress';
|
||||
import { TranslationComplete } from './TranslationComplete';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||
xlsx: FileSpreadsheet,
|
||||
docx: FileText,
|
||||
pptx: Presentation,
|
||||
xlsx: FileSpreadsheet, docx: FileText, pptx: Presentation, pdf: FileType,
|
||||
};
|
||||
const FILE_COLORS: Record<string, string> = {
|
||||
xlsx: 'text-green-500',
|
||||
docx: 'text-blue-500',
|
||||
pptx: 'text-orange-500',
|
||||
xlsx: 'text-green-500', docx: 'text-blue-500', pptx: 'text-orange-500', pdf: 'text-red-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>
|
||||
);
|
||||
/* ── Quality label based on provider ────────────────────────────── */
|
||||
function getQualityLabel(t: (key: string) => string, provider: string | null | undefined): string {
|
||||
if (!provider) return t('dashboard.translate.highQuality');
|
||||
if (['openai', 'openrouter', 'openrouter_premium'].includes(provider)) return t('dashboard.translate.highQuality');
|
||||
if (provider === 'deepl') return t('dashboard.translate.highQuality');
|
||||
return t('dashboard.translate.quality');
|
||||
}
|
||||
|
||||
/* ── 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>
|
||||
);
|
||||
/* ── Pipeline step keys ─────────────────────────────────────────── */
|
||||
const PIPELINE_STEP_KEYS = [
|
||||
'dashboard.translate.pipeline.upload',
|
||||
'dashboard.translate.pipeline.analyze',
|
||||
'dashboard.translate.pipeline.translate',
|
||||
'dashboard.translate.pipeline.rebuild',
|
||||
'dashboard.translate.pipeline.finalize',
|
||||
] as const;
|
||||
|
||||
const PIPELINE_ICONS = [Upload, Search, Languages, Wrench, CheckCircle2] as const;
|
||||
const PIPELINE_STARTS = [0, 10, 25, 75, 92];
|
||||
|
||||
function getActiveStepIdx(progress: number) {
|
||||
for (let i = PIPELINE_STARTS.length - 1; i >= 0; i--) {
|
||||
if (progress >= PIPELINE_STARTS[i]) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number) {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Page ────────────────────────────────────────────────────────── */
|
||||
@@ -112,201 +76,528 @@ export default function TranslatePage() {
|
||||
const { t } = useI18n();
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (submit.error && submit.error !== lastErrorRef.current) {
|
||||
lastErrorRef.current = submit.error;
|
||||
showError({
|
||||
title: t('dashboard.translate.errorNotificationTitle'),
|
||||
description: submit.error,
|
||||
});
|
||||
showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error });
|
||||
}
|
||||
}, [submit.error, showError, t]);
|
||||
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if ((submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed') {
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||
} else {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [submit.status, submit.isSubmitting]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!upload.file || !config.isConfigValid) return;
|
||||
await submit.submitTranslation(upload.file, config.getConfig());
|
||||
const cfg = config.getConfig();
|
||||
if (isPdf) cfg.pdfMode = pdfMode;
|
||||
await submit.submitTranslation(upload.file, cfg);
|
||||
};
|
||||
|
||||
const handleNewTranslation = () => {
|
||||
submit.reset();
|
||||
upload.removeFile();
|
||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
|
||||
const handleDownload = async () => {
|
||||
if (!submit.jobId) return;
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
|
||||
if (!response.ok) return;
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (match?.[1]) downloadFilename = match[1];
|
||||
} else if (submit.fileName) {
|
||||
const ext = submit.fileName.split('.').pop() || '';
|
||||
const base = submit.fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${base}_translated.${ext}`;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = downloadFilename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
/* ── Derived states ──────────────────────────────────────────── */
|
||||
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
||||
const isProcessing =
|
||||
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isCompleted = submit.status === 'completed';
|
||||
const isFailed = submit.status === 'failed';
|
||||
|
||||
/* ── 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="flex flex-1 flex-col">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
<TrustRow />
|
||||
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
|
||||
const showConfiguring = isConfiguring;
|
||||
const showProcessing = isProcessing;
|
||||
const showComplete = isCompleted && !!submit.jobId;
|
||||
const showFailed = isFailed;
|
||||
|
||||
const currentProvider = config.availableProviders.find(p => p.id === config.provider);
|
||||
const srcLangName = config.languages.find(l => l.code === config.sourceLang)?.name || config.sourceLang;
|
||||
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
|
||||
const activeStepIdx = getActiveStepIdx(submit.progress);
|
||||
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
/* UNIFIED LAYOUT — always the same grid */
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
|
||||
{/* Header */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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>
|
||||
{/* Grid: LEFT (2/3) + RIGHT (1/3) — always present */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* 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>}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* LEFT CARD (2/3) — content swaps based on state */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
{/* ── UPLOAD STATE: Dropzone ─────────────────────────── */}
|
||||
{showUpload && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
</div>
|
||||
<div className="flex-1 px-6 pb-6">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Provider selector — full width */}
|
||||
<ProviderSelector
|
||||
provider={config.provider}
|
||||
onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders}
|
||||
isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
{/* ── CONFIGURING STATE: File strip ──────────────────── */}
|
||||
{showConfiguring && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── PROCESSING STATE: Rich progress ────────────────── */}
|
||||
{showProcessing && (
|
||||
<div className="flex flex-col">
|
||||
{/* Header band */}
|
||||
<div className="border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.translating')}</h2>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[300px]">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{submit.estimatedRemaining != null && submit.estimatedRemaining > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
|
||||
<Clock className="size-3.5" />
|
||||
~{submit.estimatedRemaining}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Pipeline stepper */}
|
||||
<PipelineStepper activeIdx={activeStepIdx} t={t} />
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground animate-pulse truncate">
|
||||
{submit.currentStep || (submit.isSubmitting ? t('dashboard.translate.steps.uploading') : t('dashboard.translate.steps.starting'))}
|
||||
</p>
|
||||
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
|
||||
{Math.round(submit.progress)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary via-primary/80 to-primary transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.max(0, submit.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live stats */}
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
<StatBox icon={<FileText className="size-4" />} value={`${Math.round(submit.progress)}%`} label={t('dashboard.translate.segments')} />
|
||||
<StatBox icon={<Activity className="size-4" />} value={t('dashboard.translate.translating')} label={t('dashboard.translate.characters')} />
|
||||
<StatBox icon={<Gauge className="size-4" />} value="—" label={t('dashboard.translate.segPerMin')} />
|
||||
<StatBox icon={<Timer className="size-4" />} value={formatElapsed(elapsed)} label={t('dashboard.translate.elapsed')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── COMPLETE STATE: Success ────────────────────────── */}
|
||||
{showComplete && (
|
||||
<div className="flex flex-col">
|
||||
{/* Success header */}
|
||||
<div className="border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
||||
<CheckCircle2 className="size-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.completed')}</h2>
|
||||
{submit.fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{submit.fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<TrendingUp className="size-3" />{qualityLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="lg" className="h-11 gap-2 flex-1 font-semibold" onClick={handleDownload}>
|
||||
<Download className="size-4" />{t('dashboard.translate.complete.download')}
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="h-11 gap-2 flex-1" onClick={handleNewTranslation}>
|
||||
<Plus className="size-4" />{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── FAILED STATE ───────────────────────────────────── */}
|
||||
{showFailed && (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
||||
<p className="text-sm text-destructive/80">{submit.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* RIGHT CARD (1/3) — Config / Monitor / Summary */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
||||
{(showUpload || showConfiguring || showFailed) && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.configuration')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-5 px-6">
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* PDF mode selector — only shown for PDF files */}
|
||||
{isPdf && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.pdfMode.title')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('layout')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'layout'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<FileText className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('text_only')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'text_only'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Languages className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.textOnly')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with button */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
size="lg" className="h-12 w-full gap-2 text-base font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
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 flex 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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── MONITOR (processing) ────────────────────────────── */}
|
||||
{showProcessing && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
|
||||
<div className="size-2 animate-pulse rounded-full bg-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('dashboard.translate.liveMonitor')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-6">
|
||||
{/* File summary */}
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-primary/5 border border-primary/10 p-3">
|
||||
{(() => {
|
||||
const name = submit.fileName || upload.file?.name || '';
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
return <FileIcon className={`size-6 shrink-0 ${color}`} />;
|
||||
})()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{submit.fileName || upload.file?.name}</p>
|
||||
{upload.file && <p className="text-[11px] text-muted-foreground">{fmt(upload.file.size)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config summary */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quality indicator */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-700"
|
||||
style={{ width: `${Math.min(95, 40 + submit.progress * 0.55)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-destructive/20 text-destructive hover:bg-destructive hover:text-white hover:border-destructive"
|
||||
onClick={handleNewTranslation}
|
||||
>
|
||||
<RotateCcw className="size-4" />{t('dashboard.translate.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
||||
{showComplete && (
|
||||
<>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />{t('dashboard.translate.summary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality bar */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: '95%' }} />
|
||||
</div>
|
||||
</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;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══ Sub-components ═══════════════════════════════════════════════ */
|
||||
|
||||
/** Compact file strip */
|
||||
function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: () => void; onReplace: () => void; t: (key: string) => string }) {
|
||||
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.replace')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pipeline stepper */
|
||||
function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
{PIPELINE_STEP_KEYS.map((stepKey, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
const isDone = i < activeIdx;
|
||||
const Icon = PIPELINE_ICONS[i];
|
||||
return (
|
||||
<div key={stepKey} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEP_KEYS.length - 1 ? 1 : 'none' }}>
|
||||
<div className="flex items-center w-full">
|
||||
<div className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
||||
isDone
|
||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{isDone ? <CheckCircle2 className="size-4" /> : <Icon className={cn('size-4', isActive && 'animate-pulse')} />}
|
||||
</div>
|
||||
{i < PIPELINE_STEP_KEYS.length - 1 && (
|
||||
<div className={cn('mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500', i < activeIdx ? 'bg-primary' : 'bg-border')} />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('text-[11px] font-medium transition-colors', isDone || isActive ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{t(stepKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small stat box */
|
||||
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
|
||||
<div className="text-primary">{icon}</div>
|
||||
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface TranslationConfig {
|
||||
targetLang: string;
|
||||
mode: TranslationMode;
|
||||
provider?: Provider;
|
||||
pdfMode?: 'layout' | 'text_only';
|
||||
}
|
||||
|
||||
export interface UseTranslationConfigReturn {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { UseFileUploadReturn } from './types';
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx'];
|
||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx',
|
||||
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf',
|
||||
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AvailableProvider,
|
||||
} from './types';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
|
||||
/** Fallback when API fails — Google is always available server-side */
|
||||
const FALLBACK_PROVIDERS: AvailableProvider[] = [
|
||||
@@ -59,8 +60,9 @@ const FALLBACK_LANGUAGES: Language[] = [
|
||||
];
|
||||
|
||||
export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn {
|
||||
const { settings } = useTranslationStore();
|
||||
const [sourceLang, setSourceLang] = useState('auto');
|
||||
const [targetLang, setTargetLang] = useState('');
|
||||
const [targetLang, setTargetLang] = useState(settings.defaultTargetLanguage || '');
|
||||
const [provider, setProvider] = useState<Provider | null>(null);
|
||||
const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]);
|
||||
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
|
||||
@@ -69,6 +71,13 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
const [isLoadingLanguages, setIsLoadingLanguages] = useState(false);
|
||||
const [languagesError, setLanguagesError] = useState<string | null>(null);
|
||||
|
||||
// Sync with store default target language
|
||||
useEffect(() => {
|
||||
if (settings.defaultTargetLanguage && !targetLang) {
|
||||
setTargetLang(settings.defaultTargetLanguage);
|
||||
}
|
||||
}, [settings.defaultTargetLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch available (admin-configured) providers
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -105,6 +114,16 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
return () => { controller.abort(); clearTimeout(timeoutId); };
|
||||
}, []);
|
||||
|
||||
// Auto-select first classic provider for non-Pro users
|
||||
useEffect(() => {
|
||||
if (isLoadingProviders) return;
|
||||
if (provider !== null) return;
|
||||
if (isPro) return;
|
||||
if (availableProviders.length === 0) return;
|
||||
const firstClassic = availableProviders.find((p) => p.mode === 'classic');
|
||||
if (firstClassic) setProvider(firstClassic.id);
|
||||
}, [availableProviders, isLoadingProviders, isPro, provider]);
|
||||
|
||||
// Fetch supported languages
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -148,11 +167,12 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
// Check user tier
|
||||
useEffect(() => {
|
||||
const checkTier = async () => {
|
||||
const isProTier = (u: any) => ['pro', 'business', 'enterprise'].includes(u?.plan ?? u?.tier ?? '');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
if (user.tier) { setIsPro(user.tier === 'pro'); return; }
|
||||
if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
try {
|
||||
@@ -164,7 +184,7 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const user = result.data;
|
||||
setIsPro(user.tier === 'pro');
|
||||
setIsPro(isProTier(user));
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
} else {
|
||||
setIsPro(false);
|
||||
|
||||
@@ -62,6 +62,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
setError('Translation job not found');
|
||||
return;
|
||||
}
|
||||
// 429 (rate-limited) is not a real failure — just skip this poll
|
||||
if (response.status === 429) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -135,6 +139,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
if (config.mode === 'llm' && config.provider) {
|
||||
formData.append('provider', config.provider);
|
||||
}
|
||||
// PDF mode: layout (preserve layout) or text_only (clean text output)
|
||||
if (config.pdfMode) {
|
||||
formData.append('pdf_mode', config.pdfMode);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useUser(): UseQueryResult<User, ApiClientError> {
|
||||
return useQuery({
|
||||
queryKey: ['user', 'me'],
|
||||
queryFn: async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>('/api/v1/auth/me');
|
||||
const response = await apiClient.get<{ data: User; meta: Record<string, unknown> }>('/api/v1/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html suppressHydrationWarning>
|
||||
<body className={`${inter.className} bg-background text-foreground antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
||||
<I18nProvider>
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Types
|
||||
@@ -50,11 +51,13 @@ interface CreditPackage {
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Static plan data (fallback + SSR-friendly)
|
||||
Names, descriptions, features, badges use i18n keys
|
||||
resolved via t() in the render layer.
|
||||
───────────────────────────────────────────── */
|
||||
const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Gratuit",
|
||||
name: "pricing.plans.free.name",
|
||||
price_monthly: 0,
|
||||
price_yearly: 0,
|
||||
docs_per_month: 5,
|
||||
@@ -63,21 +66,21 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 50_000,
|
||||
providers: ["google"],
|
||||
features: [
|
||||
"5 documents / mois",
|
||||
"Jusqu'à 15 pages par document",
|
||||
"Google Traduction inclus",
|
||||
"Toutes les langues (130+)",
|
||||
"Support communautaire",
|
||||
"pricing.plans.free.feat1",
|
||||
"pricing.plans.free.feat2",
|
||||
"pricing.plans.free.feat3",
|
||||
"pricing.plans.free.feat4",
|
||||
"pricing.plans.free.feat5",
|
||||
],
|
||||
ai_translation: false,
|
||||
api_access: false,
|
||||
priority_processing: false,
|
||||
popular: false,
|
||||
description: "Parfait pour découvrir l'application",
|
||||
description: "pricing.plans.free.description",
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
name: "pricing.plans.starter.name",
|
||||
price_monthly: 9.00,
|
||||
price_yearly: 86.40,
|
||||
docs_per_month: 50,
|
||||
@@ -86,22 +89,22 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 500_000,
|
||||
providers: ["google", "deepl"],
|
||||
features: [
|
||||
"50 documents / mois",
|
||||
"Jusqu'à 50 pages par document",
|
||||
"Google Traduction + DeepL",
|
||||
"Fichiers jusqu'à 10 Mo",
|
||||
"Support par e-mail",
|
||||
"Historique 30 jours",
|
||||
"pricing.plans.starter.feat1",
|
||||
"pricing.plans.starter.feat2",
|
||||
"pricing.plans.starter.feat3",
|
||||
"pricing.plans.starter.feat4",
|
||||
"pricing.plans.starter.feat5",
|
||||
"pricing.plans.starter.feat6",
|
||||
],
|
||||
ai_translation: false,
|
||||
api_access: false,
|
||||
priority_processing: false,
|
||||
popular: false,
|
||||
description: "Pour les particuliers et petits projets",
|
||||
description: "pricing.plans.starter.description",
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
name: "pricing.plans.pro.name",
|
||||
price_monthly: 19.00,
|
||||
price_yearly: 182.40,
|
||||
docs_per_month: 200,
|
||||
@@ -110,27 +113,27 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 2_000_000,
|
||||
providers: ["google", "deepl", "openrouter"],
|
||||
features: [
|
||||
"200 documents / mois",
|
||||
"Jusqu'à 200 pages par document",
|
||||
"Traduction IA Essentielle (DeepSeek V3.2)",
|
||||
"Google Traduction + DeepL",
|
||||
"Fichiers jusqu'à 25 Mo",
|
||||
"Glossaires personnalisés",
|
||||
"Support prioritaire",
|
||||
"Historique 90 jours",
|
||||
"pricing.plans.pro.feat1",
|
||||
"pricing.plans.pro.feat2",
|
||||
"pricing.plans.pro.feat3",
|
||||
"pricing.plans.pro.feat4",
|
||||
"pricing.plans.pro.feat5",
|
||||
"pricing.plans.pro.feat6",
|
||||
"pricing.plans.pro.feat7",
|
||||
"pricing.plans.pro.feat8",
|
||||
],
|
||||
ai_translation: true,
|
||||
ai_tier: "essential",
|
||||
api_access: false,
|
||||
priority_processing: true,
|
||||
popular: true,
|
||||
highlight: "Le plus populaire",
|
||||
description: "Pour les professionnels et équipes en croissance",
|
||||
badge: "POPULAIRE",
|
||||
highlight: "pricing.plans.pro.highlight",
|
||||
description: "pricing.plans.pro.description",
|
||||
badge: "pricing.plans.pro.badge",
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
name: "pricing.plans.business.name",
|
||||
price_monthly: 49.00,
|
||||
price_yearly: 470.40,
|
||||
docs_per_month: 1000,
|
||||
@@ -139,16 +142,16 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 10_000_000,
|
||||
providers: ["google", "deepl", "openrouter", "openrouter_premium", "openai"],
|
||||
features: [
|
||||
"1 000 documents / mois",
|
||||
"Jusqu'à 500 pages par document",
|
||||
"IA Essentielle + Premium (Claude Haiku)",
|
||||
"Tous les fournisseurs de traduction",
|
||||
"Fichiers jusqu'à 50 Mo",
|
||||
"Accès API (10 000 appels/mois)",
|
||||
"Webhooks de notification",
|
||||
"Support dédié",
|
||||
"Historique 1 an",
|
||||
"Analytiques avancées",
|
||||
"pricing.plans.business.feat1",
|
||||
"pricing.plans.business.feat2",
|
||||
"pricing.plans.business.feat3",
|
||||
"pricing.plans.business.feat4",
|
||||
"pricing.plans.business.feat5",
|
||||
"pricing.plans.business.feat6",
|
||||
"pricing.plans.business.feat7",
|
||||
"pricing.plans.business.feat8",
|
||||
"pricing.plans.business.feat9",
|
||||
"pricing.plans.business.feat10",
|
||||
],
|
||||
ai_translation: true,
|
||||
ai_tier: "premium",
|
||||
@@ -156,11 +159,11 @@ const STATIC_PLANS: Plan[] = [
|
||||
priority_processing: true,
|
||||
team_seats: 5,
|
||||
popular: false,
|
||||
description: "Pour les équipes et organisations",
|
||||
description: "pricing.plans.business.description",
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Entreprise",
|
||||
name: "pricing.plans.enterprise.name",
|
||||
price_monthly: -1,
|
||||
price_yearly: -1,
|
||||
docs_per_month: -1,
|
||||
@@ -169,14 +172,14 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: -1,
|
||||
providers: ["all"],
|
||||
features: [
|
||||
"Documents illimités",
|
||||
"Tous les modèles IA (GPT-5, Claude Opus 4.6…)",
|
||||
"Déploiement on-premise ou cloud dédié",
|
||||
"SLA 99,9 % garanti",
|
||||
"Support 24/7 dédié",
|
||||
"Marque blanche (white-label)",
|
||||
"Équipes illimitées",
|
||||
"Intégrations sur mesure",
|
||||
"pricing.plans.enterprise.feat1",
|
||||
"pricing.plans.enterprise.feat2",
|
||||
"pricing.plans.enterprise.feat3",
|
||||
"pricing.plans.enterprise.feat4",
|
||||
"pricing.plans.enterprise.feat5",
|
||||
"pricing.plans.enterprise.feat6",
|
||||
"pricing.plans.enterprise.feat7",
|
||||
"pricing.plans.enterprise.feat8",
|
||||
],
|
||||
ai_translation: true,
|
||||
ai_tier: "custom",
|
||||
@@ -184,8 +187,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
priority_processing: true,
|
||||
team_seats: -1,
|
||||
popular: false,
|
||||
description: "Solutions sur mesure pour grandes organisations",
|
||||
badge: "SUR DEVIS",
|
||||
description: "pricing.plans.enterprise.description",
|
||||
badge: "pricing.plans.enterprise.badge",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -215,7 +218,7 @@ 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. */
|
||||
/** Avoids flash of static prices before the API responds on refresh. */
|
||||
function PricingDataSkeleton() {
|
||||
return (
|
||||
<>
|
||||
@@ -257,40 +260,20 @@ function PricingDataSkeleton() {
|
||||
}
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Puis-je changer de forfait à tout moment ?",
|
||||
a: "Oui. Le passage à un forfait supérieur est immédiat et proratisé. La rétrogradation prend effet à la fin de la période en cours.",
|
||||
},
|
||||
{
|
||||
q: "Qu'est-ce que la « Traduction IA Essentielle » ?",
|
||||
a: "C'est notre moteur IA basé sur DeepSeek V3.2 via OpenRouter. Il comprend le contexte de vos documents, préserve la mise en page et gère les termes techniques bien mieux qu'une traduction classique.",
|
||||
},
|
||||
{
|
||||
q: "Quelle est la différence entre IA Essentielle et IA Premium ?",
|
||||
a: "L'IA Essentielle utilise DeepSeek V3.2 (excellent rapport qualité/prix). L'IA Premium utilise Claude 3.5 Haiku d'Anthropic, plus précis sur les documents juridiques, médicaux et techniques complexes.",
|
||||
},
|
||||
{
|
||||
q: "Mes documents sont-ils conservés après traduction ?",
|
||||
a: "Les fichiers traduits sont disponibles selon votre forfait (30 jours Starter, 90 jours Pro, 1 an Business). Ils sont chiffrés au repos et en transit.",
|
||||
},
|
||||
{
|
||||
q: "Que se passe-t-il si je dépasse mon quota mensuel ?",
|
||||
a: "Vous pouvez acheter des crédits supplémentaires à l'unité, ou upgrader votre forfait. Vous êtes notifié à 80 % d'utilisation.",
|
||||
},
|
||||
{
|
||||
q: "Y a-t-il une version d'essai gratuite des forfaits payants ?",
|
||||
a: "Le forfait Gratuit est permanent et sans carte bancaire. Pour les forfaits Pro et Business, contactez-nous pour un accès d'essai de 14 jours.",
|
||||
},
|
||||
{
|
||||
q: "Quels formats de fichiers sont supportés ?",
|
||||
a: "Word (.docx), Excel (.xlsx/.xls), PowerPoint (.pptx), et bientôt PDF. Tous les plans supportent les mêmes formats.",
|
||||
},
|
||||
{ q: "pricing.faq.q1", a: "pricing.faq.a1" },
|
||||
{ q: "pricing.faq.q2", a: "pricing.faq.a2" },
|
||||
{ q: "pricing.faq.q3", a: "pricing.faq.a3" },
|
||||
{ q: "pricing.faq.q4", a: "pricing.faq.a4" },
|
||||
{ q: "pricing.faq.q5", a: "pricing.faq.a5" },
|
||||
{ q: "pricing.faq.q6", a: "pricing.faq.a6" },
|
||||
{ q: "pricing.faq.q7", a: "pricing.faq.a7" },
|
||||
];
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Main component
|
||||
───────────────────────────────────────────── */
|
||||
export default function PricingPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const planFromUrl = searchParams.get("plan");
|
||||
@@ -303,11 +286,11 @@ export default function PricingPage() {
|
||||
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
||||
const [toastMsg, setToastMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
|
||||
/** Tant que false : on n’affiche pas STATIC_PLANS (évite le flash de faux prix au refresh). */
|
||||
/** Until false: don't show STATIC_PLANS (avoids flash of stale prices on refresh). */
|
||||
const [pricingLoaded, setPricingLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch live plans — backend returns prices set by admin (pas de cache navigateur)
|
||||
// Fetch live plans — backend returns prices set by admin (no browser cache)
|
||||
const plansUrl = `${API_BASE}/api/v1/auth/plans`;
|
||||
fetch(plansUrl, { cache: "no-store" })
|
||||
.then(async (r) => {
|
||||
@@ -327,7 +310,7 @@ export default function PricingPage() {
|
||||
})
|
||||
.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);
|
||||
console.warn("[pricing] Cannot load /api/v1/auth/plans — showing default prices.", err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -354,7 +337,7 @@ export default function PricingPage() {
|
||||
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
|
||||
// Directly initiate checkout
|
||||
handleSubscribe(planFromUrl);
|
||||
}
|
||||
}
|
||||
@@ -399,7 +382,7 @@ export default function PricingPage() {
|
||||
const backendMessage = data.message ?? data.error ?? data.data?.error;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(backendMessage || "Erreur lors de la création du paiement.");
|
||||
throw new Error(backendMessage || t('pricing.toast.paymentError'));
|
||||
}
|
||||
|
||||
if (url) {
|
||||
@@ -408,11 +391,11 @@ export default function PricingPage() {
|
||||
// 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}.`,
|
||||
text: t('pricing.toast.demo', { planId }),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur réseau. Veuillez réessayer.";
|
||||
const message = error instanceof Error ? error.message : t('pricing.toast.networkError');
|
||||
setToastMsg({ type: 'err', text: message });
|
||||
} finally {
|
||||
setLoadingPlanId(null);
|
||||
@@ -429,21 +412,21 @@ export default function PricingPage() {
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Retour
|
||||
{t('pricing.nav.back')}
|
||||
</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"}
|
||||
{isLoggedIn ? "Dashboard" : t('pricing.nav.home')}
|
||||
</Link>
|
||||
{isLoggedIn && (
|
||||
<Link
|
||||
href="/settings/subscription"
|
||||
href="/dashboard/profile"
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-accent/80 hover:bg-accent text-accent-foreground transition-colors"
|
||||
>
|
||||
Mon abonnement
|
||||
{t('pricing.nav.mySubscription')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
@@ -468,14 +451,13 @@ export default function PricingPage() {
|
||||
<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-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
|
||||
{t('pricing.header.badge')}
|
||||
</div>
|
||||
<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
|
||||
{t('pricing.header.title')}
|
||||
</h1>
|
||||
<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.
|
||||
{t('pricing.header.subtitle')}
|
||||
</p>
|
||||
|
||||
{/* Monthly / Yearly toggle */}
|
||||
@@ -487,7 +469,7 @@ export default function PricingPage() {
|
||||
!isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Mensuel
|
||||
{t('pricing.billing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsYearly(true)}
|
||||
@@ -496,7 +478,7 @@ export default function PricingPage() {
|
||||
isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Annuel
|
||||
{t('pricing.billing.yearly')}
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
−{annualDiscountPercent} %
|
||||
</span>
|
||||
@@ -504,7 +486,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards (+ comparaison + crédits) : squelette jusqu’à l’API pour éviter le flash des tarifs statiques */}
|
||||
{/* ── Plan cards (+ comparison + credits): skeleton until API responds to avoid stale price flash ── */}
|
||||
<div className="max-w-7xl mx-auto px-4 pb-20">
|
||||
{!pricingLoaded ? (
|
||||
<PricingDataSkeleton />
|
||||
@@ -534,13 +516,13 @@ export default function PricingPage() {
|
||||
"absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold text-white",
|
||||
colors.badge
|
||||
)}>
|
||||
{plan.badge}
|
||||
{t(plan.badge)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-3 right-4 px-3 py-1 rounded-full text-xs font-bold bg-emerald-600 text-white flex items-center gap-1">
|
||||
<BadgeCheck className="w-3 h-3" /> Mon forfait
|
||||
<BadgeCheck className="w-3 h-3" /> {t('pricing.card.myPlan')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -550,27 +532,27 @@ export default function PricingPage() {
|
||||
<div className="p-1.5 bg-white/10 rounded-lg">
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white">{plan.name}</span>
|
||||
<span className="font-bold text-white">{t(plan.name)}</span>
|
||||
</div>
|
||||
|
||||
{isEnterprise ? (
|
||||
<div className="text-3xl font-bold text-white">Sur devis</div>
|
||||
<div className="text-3xl font-bold text-white">{t('pricing.card.onRequest')}</div>
|
||||
) : price === 0 ? (
|
||||
<div className="text-3xl font-bold text-white">Gratuit</div>
|
||||
<div className="text-3xl font-bold text-white">{t('pricing.card.free')}</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-3xl font-bold text-white">{price} €</span>
|
||||
<span className="text-white/70 text-sm pb-1">/mois</span>
|
||||
<span className="text-white/70 text-sm pb-1">{t('pricing.card.perMonth')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isYearly && plan.price_yearly > 0 && (
|
||||
<div className="text-white/70 text-xs mt-1">
|
||||
Facturé {plan.price_yearly.toFixed(2)} € / an
|
||||
{t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-white/60 text-xs mt-2">{plan.description}</p>
|
||||
<p className="text-white/60 text-xs mt-2">{t(plan.description || '')}</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
@@ -579,21 +561,21 @@ export default function PricingPage() {
|
||||
<div className="grid grid-cols-1 gap-2 mb-3">
|
||||
<Stat
|
||||
icon={<FileText className="w-3.5 h-3.5" />}
|
||||
label="Documents"
|
||||
value={plan.docs_per_month === -1 ? "Illimité" : `${plan.docs_per_month} / mois`}
|
||||
label={t('pricing.card.documents')}
|
||||
value={plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Layers className="w-3.5 h-3.5" />}
|
||||
label="Pages max"
|
||||
value={plan.max_pages_per_doc === -1 ? "Illimité" : `${plan.max_pages_per_doc} p / doc`}
|
||||
label={t('pricing.card.pagesMax')}
|
||||
value={plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
|
||||
/>
|
||||
{plan.ai_translation && (
|
||||
<Stat
|
||||
icon={<Brain className="w-3.5 h-3.5 text-violet-400" />}
|
||||
label="Traduction IA"
|
||||
label={t('pricing.card.aiTranslation')}
|
||||
value={
|
||||
plan.ai_tier === "essential" ? "Essentielle" :
|
||||
plan.ai_tier === "premium" ? "Essentielle + Premium" : "Sur mesure"
|
||||
plan.ai_tier === "essential" ? t('pricing.card.aiEssential') :
|
||||
plan.ai_tier === "premium" ? t('pricing.card.aiEssentialPremium') : t('pricing.card.aiCustom')
|
||||
}
|
||||
highlight
|
||||
/>
|
||||
@@ -604,7 +586,7 @@ export default function PricingPage() {
|
||||
{plan.features.map((feat, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<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>
|
||||
<span className="text-muted-foreground text-xs leading-snug">{t(feat)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -616,9 +598,9 @@ export default function PricingPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-success/50 text-success hover:bg-success/10"
|
||||
onClick={() => router.push("/settings/subscription")}
|
||||
onClick={() => router.push("/dashboard/profile")}
|
||||
>
|
||||
<BadgeCheck className="w-4 h-4 mr-1" /> Gérer mon forfait
|
||||
<BadgeCheck className="w-4 h-4 me-1" /> {t('pricing.card.managePlan')}
|
||||
</Button>
|
||||
) : plan.id === "free" && !currentPlan ? (
|
||||
<Button
|
||||
@@ -626,7 +608,7 @@ export default function PricingPage() {
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted/30"
|
||||
onClick={() => router.push("/auth/register")}
|
||||
>
|
||||
Commencer gratuitement
|
||||
{t('pricing.card.startFree')}
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
@@ -644,11 +626,11 @@ export default function PricingPage() {
|
||||
<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…
|
||||
{t('pricing.card.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEnterprise ? "Nous contacter" : "Choisir ce forfait"}
|
||||
{isEnterprise ? t('pricing.card.contactUs') : t('pricing.card.choosePlan')}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
@@ -662,33 +644,33 @@ 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-muted-foreground text-center mb-10">Tout ce qui est inclus dans chaque forfait</p>
|
||||
<h2 className="text-3xl font-bold text-center mb-2">{t('pricing.comparison.title')}</h2>
|
||||
<p className="text-muted-foreground text-center mb-10">{t('pricing.comparison.subtitle')}</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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>
|
||||
<th className="text-start py-4 px-6 text-muted-foreground font-medium">{t('pricing.comparison.feature')}</th>
|
||||
{plans.slice(0, 4).map((p) => (
|
||||
<th key={p.id} className={cn("py-4 px-4 text-center font-medium", p.popular ? "text-accent" : "text-muted-foreground")}>
|
||||
{p.name}
|
||||
{t(p.name)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ label: "Documents / mois", vals: plans.slice(0,4).map(p => p.docs_per_month === -1 ? "∞" : String(p.docs_per_month)) },
|
||||
{ label: "Pages max / document", vals: plans.slice(0,4).map(p => p.max_pages_per_doc === -1 ? "∞" : String(p.max_pages_per_doc)) },
|
||||
{ label: "Taille max fichier", vals: plans.slice(0,4).map(p => p.max_file_size_mb === -1 ? "∞" : `${p.max_file_size_mb} Mo`) },
|
||||
{ label: "Google Traduction", vals: plans.slice(0,4).map(() => true) },
|
||||
{ label: "DeepL", vals: plans.slice(0,4).map(p => p.providers.includes("deepl") || p.providers.includes("all")) },
|
||||
{ label: "Traduction IA Essentielle", vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "essential" || p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: "Traduction IA Premium", vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: "Accès API", vals: plans.slice(0,4).map(p => p.api_access) },
|
||||
{ label: "Traitement prioritaire", vals: plans.slice(0,4).map(p => p.priority_processing) },
|
||||
{ label: "Support", vals: ["Communauté", "E-mail", "Prioritaire", "Dédié"] },
|
||||
{ label: t('pricing.comparison.docsPerMonth'), vals: plans.slice(0,4).map(p => p.docs_per_month === -1 ? "∞" : String(p.docs_per_month)) },
|
||||
{ label: t('pricing.comparison.pagesMaxPerDoc'), vals: plans.slice(0,4).map(p => p.max_pages_per_doc === -1 ? "∞" : String(p.max_pages_per_doc)) },
|
||||
{ label: t('pricing.comparison.maxFileSize'), vals: plans.slice(0,4).map(p => p.max_file_size_mb === -1 ? "∞" : `${p.max_file_size_mb} ${t('pricing.comparison.mb')}`) },
|
||||
{ label: t('pricing.comparison.googleTranslation'), vals: plans.slice(0,4).map(() => true) },
|
||||
{ label: t('pricing.comparison.deepl'), vals: plans.slice(0,4).map(p => p.providers.includes("deepl") || p.providers.includes("all")) },
|
||||
{ label: t('pricing.comparison.aiEssential'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "essential" || p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: t('pricing.comparison.aiPremium'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: t('pricing.comparison.apiAccess'), vals: plans.slice(0,4).map(p => p.api_access) },
|
||||
{ label: t('pricing.comparison.priorityProcessing'), vals: plans.slice(0,4).map(p => p.priority_processing) },
|
||||
{ label: t('pricing.comparison.support'), vals: [t('pricing.comparison.support.community'), t('pricing.comparison.support.email'), t('pricing.comparison.support.priority'), t('pricing.comparison.support.dedicated')] },
|
||||
].map((row, i) => (
|
||||
<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>
|
||||
@@ -714,10 +696,10 @@ export default function PricingPage() {
|
||||
|
||||
{/* ── Credits ── */}
|
||||
<div className="mt-20">
|
||||
<h2 className="text-3xl font-bold text-center mb-2">Crédits supplémentaires</h2>
|
||||
<h2 className="text-3xl font-bold text-center mb-2">{t('pricing.credits.title')}</h2>
|
||||
<p className="text-muted-foreground text-center mb-8">
|
||||
Besoin de plus ? Achetez des crédits à l'unité, sans abonnement.
|
||||
<span className="text-muted-foreground/70"> 1 crédit = 1 page traduite.</span>
|
||||
{t('pricing.credits.subtitle')}
|
||||
<span className="text-muted-foreground/70"> {t('pricing.credits.perPage')}</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
|
||||
{credits.map((pkg, i) => (
|
||||
@@ -732,15 +714,15 @@ export default function PricingPage() {
|
||||
>
|
||||
{pkg.popular && (
|
||||
<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
|
||||
{t('pricing.credits.bestValue')}
|
||||
</div>
|
||||
)}
|
||||
<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-muted-foreground text-xs mb-3">{t('pricing.credits.unit')}</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>
|
||||
<div className="text-muted-foreground text-xs">{(pkg.price_per_credit * 100).toFixed(0)} {t('pricing.credits.centsPerCredit')}</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
|
||||
{t('pricing.credits.buy')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -752,15 +734,15 @@ export default function PricingPage() {
|
||||
{/* ── 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-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) => (
|
||||
{ icon: <Shield className="w-6 h-6 text-emerald-500" />, title: t('pricing.trust.encryption.title'), sub: t('pricing.trust.encryption.sub') },
|
||||
{ icon: <Globe className="w-6 h-6 text-blue-500" />, title: t('pricing.trust.languages.title'), sub: t('pricing.trust.languages.sub') },
|
||||
{ icon: <Gauge className="w-6 h-6 text-accent" />, title: t('pricing.trust.parallel.title'), sub: t('pricing.trust.parallel.sub') },
|
||||
{ icon: <Clock className="w-6 h-6 text-amber-500" />, title: t('pricing.trust.availability.title'), sub: t('pricing.trust.availability.sub') },
|
||||
].map((signal, i) => (
|
||||
<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 className="p-3 rounded-full bg-muted/50 mb-3">{signal.icon}</div>
|
||||
<div className="font-semibold text-foreground text-sm mb-1">{signal.title}</div>
|
||||
<div className="text-muted-foreground text-xs">{signal.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -769,39 +751,37 @@ export default function PricingPage() {
|
||||
<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-accent" />
|
||||
<h2 className="text-2xl font-bold">Nos modèles IA — Mars 2026</h2>
|
||||
<h2 className="text-2xl font-bold">{t('pricing.aiModels.title')}</h2>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<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-500" />
|
||||
<span className="font-semibold">Traduction IA Essentielle</span>
|
||||
<Badge className="ml-auto text-xs bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">Forfait Pro</Badge>
|
||||
<span className="font-semibold">{t('pricing.aiModels.essential.title')}</span>
|
||||
<Badge className="ml-auto text-xs bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">{t('pricing.aiModels.essential.plan')}</Badge>
|
||||
</div>
|
||||
<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.
|
||||
{t('pricing.aiModels.essential.descPrefix')} <strong className="text-foreground">DeepSeek V3.2</strong> {t('pricing.aiModels.essential.descSuffix')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-muted rounded">163K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.essential.context')}</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>
|
||||
<span className="px-2 py-1 bg-success/10 text-success rounded">{t('pricing.aiModels.essential.value')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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-accent" />
|
||||
<span className="font-semibold">Traduction IA Premium</span>
|
||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">Forfait Business</Badge>
|
||||
<span className="font-semibold">{t('pricing.aiModels.premium.title')}</span>
|
||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">{t('pricing.aiModels.premium.plan')}</Badge>
|
||||
</div>
|
||||
<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.
|
||||
{t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">Claude 3.5 Haiku</strong> {t('pricing.aiModels.premium.descSuffix')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-muted rounded">200K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.premium.context')}</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>
|
||||
<span className="px-2 py-1 bg-accent/10 text-accent rounded">{t('pricing.aiModels.premium.precision')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -809,7 +789,7 @@ export default function PricingPage() {
|
||||
|
||||
{/* ── FAQ ── */}
|
||||
<div className="mt-20 max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center mb-10">Questions fréquentes</h2>
|
||||
<h2 className="text-3xl font-bold text-center mb-10">{t('pricing.faq.title')}</h2>
|
||||
<div className="space-y-3">
|
||||
{FAQS.map((faq, i) => (
|
||||
<div
|
||||
@@ -817,10 +797,10 @@ export default function PricingPage() {
|
||||
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-muted/20 transition-colors"
|
||||
className="w-full flex items-center justify-between p-5 text-start hover:bg-muted/20 transition-colors"
|
||||
onClick={() => setOpenFAQ(openFAQ === i ? null : i)}
|
||||
>
|
||||
<span className="font-medium text-foreground">{faq.q}</span>
|
||||
<span className="font-medium text-foreground">{t(faq.q)}</span>
|
||||
{openFAQ === i
|
||||
? <ChevronUp className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
: <ChevronDown className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
@@ -828,7 +808,7 @@ export default function PricingPage() {
|
||||
</button>
|
||||
{openFAQ === i && (
|
||||
<div className="px-5 pb-5 text-muted-foreground text-sm leading-relaxed border-t border-border/30 pt-4">
|
||||
{faq.a}
|
||||
{t(faq.a)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -838,20 +818,20 @@ export default function PricingPage() {
|
||||
|
||||
{/* ── CTA bottom ── */}
|
||||
<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>
|
||||
<h2 className="text-3xl font-bold mb-3">{t('pricing.cta.title')}</h2>
|
||||
<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.
|
||||
{t('pricing.cta.subtitle')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/auth/register">
|
||||
<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" />
|
||||
{t('pricing.cta.createAccount')}
|
||||
<ArrowRight className="ms-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/auth/login">
|
||||
<Button variant="outline" className="px-8 py-3 text-base">
|
||||
Se connecter
|
||||
{t('pricing.cta.login')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -567,12 +567,12 @@ export function FileUploader() {
|
||||
>
|
||||
{isTranslating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<Loader2 className="me-2 h-5 w-5 animate-spin" />
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<Zap className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
Translate Document
|
||||
</>
|
||||
)}
|
||||
@@ -630,7 +630,7 @@ export function FileUploader() {
|
||||
size="lg"
|
||||
className="group px-8"
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<Download className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
Download Translated Document
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
Globe2,
|
||||
FileText,
|
||||
Zap,
|
||||
Shield,
|
||||
Brain,
|
||||
Server
|
||||
} from "lucide-react"
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Globe2,
|
||||
title: "100+ Languages",
|
||||
description: "Translate between any language pair with high accuracy",
|
||||
color: "text-blue-400",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "Preserve Formatting",
|
||||
description: "All styles, fonts, colors, tables, and charts remain intact",
|
||||
color: "text-green-400",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Lightning Fast",
|
||||
description: "Batch processing translates entire documents in seconds",
|
||||
color: "text-amber-400",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Secure & Private",
|
||||
description: "Your documents are encrypted and never stored permanently",
|
||||
color: "text-purple-400",
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: "AI-Powered",
|
||||
description: "Advanced neural translation for natural, context-aware results",
|
||||
color: "text-teal-400",
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: "Enterprise Ready",
|
||||
description: "API access, team management, and dedicated support",
|
||||
color: "text-orange-400",
|
||||
},
|
||||
]
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<section className="py-16 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3">
|
||||
Everything You Need for Document Translation
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Professional-grade translation with enterprise features, available to everyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="flex flex-col items-center text-center p-6 rounded-xl border border-border bg-card/50 hover:bg-card transition-colors"
|
||||
>
|
||||
<div className={`mb-4 ${feature.color}`}>
|
||||
<Icon className="size-8" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import Link from "next/link"
|
||||
import { FileSpreadsheet, FileText, Presentation } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="flex flex-col items-center gap-6 px-6 pt-16 pb-8 text-center md:pt-24 md:pb-12">
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-4 py-1.5 text-xs font-medium text-muted-foreground shadow-sm">
|
||||
<span className="inline-block size-1.5 rounded-full bg-green-500" />
|
||||
Now with Pro LLM Engine
|
||||
</div>
|
||||
|
||||
<h1 className="max-w-2xl text-balance text-4xl font-bold leading-tight tracking-tight text-foreground md:text-5xl lg:text-6xl">
|
||||
Translate Office Documents. Keep the Format Perfect.
|
||||
</h1>
|
||||
|
||||
<p className="max-w-xl text-pretty text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||
Upload your Excel, Word, or PowerPoint files and get accurate translations with zero formatting loss.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/auth/register">Try Free</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild size="lg">
|
||||
<Link href="/pricing">View Pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileSpreadsheet className="size-4 text-green-500" />
|
||||
<span>.xlsx</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="size-4 text-blue-500" />
|
||||
<span>.docx</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Presentation className="size-4 text-orange-500" />
|
||||
<span>.pptx</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function HeroWordComparison() {
|
||||
</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">
|
||||
<p className="mb-3 text-center text-xs text-slate-400 sm:text-start 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]">
|
||||
@@ -86,7 +86,7 @@ export function HeroWordComparison() {
|
||||
{t("landing.heroDoc.badgeOriginal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-left">
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-start">
|
||||
<article
|
||||
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
|
||||
style={{
|
||||
@@ -108,7 +108,7 @@ export function HeroWordComparison() {
|
||||
{t("landing.heroDoc.badgeTranslated")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-left">
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-start">
|
||||
<article
|
||||
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
|
||||
style={{
|
||||
|
||||
@@ -1,226 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } 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 { useI18n } from "@/lib/i18n";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import {
|
||||
Table2,
|
||||
FileText,
|
||||
Table2,
|
||||
Presentation,
|
||||
Bot,
|
||||
Lock,
|
||||
Zap,
|
||||
FileSpreadsheet,
|
||||
Check,
|
||||
PlayCircle,
|
||||
ShieldCheck,
|
||||
Clock,
|
||||
Languages,
|
||||
Zap,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
Bot,
|
||||
Brain,
|
||||
BookOpen,
|
||||
Upload,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
|
||||
export function LandingPage() {
|
||||
const { t } = useTranslation();
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [prices, setPrices] = useState({
|
||||
starter: { monthly: 9, yearly: 86.40 },
|
||||
pro: { monthly: 19, yearly: 182.40 },
|
||||
business: { monthly: 49, yearly: 470.40 },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/auth/plans`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((json) => {
|
||||
if (!json) return;
|
||||
const d = json.data ?? json;
|
||||
const plans = Array.isArray(d.plans) ? d.plans : [];
|
||||
const map: Record<string, { monthly: number; yearly: number }> = {};
|
||||
for (const p of plans) {
|
||||
if (p.id && typeof p.price_monthly === "number") {
|
||||
map[p.id] = {
|
||||
monthly: p.price_monthly,
|
||||
yearly: typeof p.price_yearly === "number" ? p.price_yearly : p.price_monthly * 12,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (map.starter || map.pro || map.business) {
|
||||
setPrices((prev) => ({
|
||||
starter: map.starter || prev.starter,
|
||||
pro: map.pro || prev.pro,
|
||||
business: map.business || prev.business,
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground font-sans">
|
||||
{/* Header */}
|
||||
{/* ── Header ── */}
|
||||
<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">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<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-foreground text-lg font-bold tracking-tight">
|
||||
Office Translator
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
<a
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
href="#features"
|
||||
>
|
||||
{t("nav.features")}
|
||||
<a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#why">
|
||||
{t("landing.nav.whyUs")}
|
||||
</a>
|
||||
<a
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
href="#pricing"
|
||||
>
|
||||
{t("nav.pricing")}
|
||||
<a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#formats">
|
||||
{t("landing.nav.formats")}
|
||||
</a>
|
||||
<a
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
href="#enterprise"
|
||||
>
|
||||
{t("nav.enterprise")}
|
||||
<a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#pricing">
|
||||
{t("landing.nav.pricing")}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher variant="button" />
|
||||
<LanguageSwitcher variant="select" />
|
||||
<ThemeToggle />
|
||||
<a
|
||||
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")}
|
||||
{t("landing.nav.login")}
|
||||
</a>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
|
||||
>
|
||||
<Link href="/auth/register">{t("common.signup")}</Link>
|
||||
<Button asChild className="bg-primary text-primary-foreground font-bold hover:bg-primary/90">
|
||||
<Link href="/auth/register">{t("landing.nav.startFree")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-20 pb-32 overflow-hidden">
|
||||
{/* ── Hero ── */}
|
||||
<section className="relative pt-20 pb-28 overflow-hidden">
|
||||
<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-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">
|
||||
{t("hero.titleHighlight")}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/5 px-4 py-1.5 text-sm font-medium text-primary mb-8">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("landing.hero.badge")}
|
||||
</div>
|
||||
|
||||
<p className="mx-auto lg:mx-0 max-w-2xl text-lg text-muted-foreground">
|
||||
{t("hero.subtitle")}
|
||||
</p>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-6xl xl:text-7xl mx-auto max-w-5xl">
|
||||
{t("landing.hero.title1")}
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
|
||||
{t("landing.hero.title2")}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
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="gap-2"
|
||||
>
|
||||
<PlayCircle className="h-5 w-5" />
|
||||
{t("hero.demoCta")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-8 mx-auto max-w-2xl text-lg text-muted-foreground">
|
||||
{t("landing.hero.subtitle")}
|
||||
</p>
|
||||
|
||||
{/* 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-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-muted-foreground">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
<span>{t("hero.badge2")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90 text-base px-8"
|
||||
>
|
||||
<Link href="/auth/register">
|
||||
{t("landing.hero.cta")}
|
||||
<ArrowRight className="ms-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="text-base px-8" asChild>
|
||||
<Link href="#pricing">{t("landing.hero.seePlans")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Trust Badges */}
|
||||
<div className="mt-12 flex flex-wrap justify-center gap-8 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
<span>{t("landing.trust.filesDeleted")}</span>
|
||||
</div>
|
||||
|
||||
{/* Hero Visual — Word EN | FR + illustrations */}
|
||||
<div className="flex-1 w-full max-w-lg lg:max-w-none relative">
|
||||
<HeroWordComparison />
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeCheck className="h-5 w-5 text-primary" />
|
||||
<span>{t("landing.trust.noBait")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-primary" />
|
||||
<span>{t("landing.trust.preview")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format pills */}
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-3">
|
||||
{[".DOCX", ".XLSX", ".PPTX", ".PDF"].map((fmt) => (
|
||||
<span
|
||||
key={fmt}
|
||||
className="inline-flex items-center rounded-full border border-border bg-card px-4 py-2 text-sm font-mono font-semibold text-foreground"
|
||||
>
|
||||
{fmt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="py-24 bg-background relative" id="features">
|
||||
{/* ── How it works ── */}
|
||||
<section className="py-24 bg-background">
|
||||
<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-foreground sm:text-4xl mb-4">
|
||||
{t("features.title")}
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.howItWorks.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("features.subtitle")}
|
||||
{t("landing.howItWorks.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{/* Feature 1 - Excel */}
|
||||
<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 className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
{[
|
||||
{
|
||||
step: "1",
|
||||
icon: Upload,
|
||||
title: t("landing.howItWorks.step1.title"),
|
||||
desc: t("landing.howItWorks.step1.desc"),
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
step: "2",
|
||||
icon: ArrowLeftRight,
|
||||
title: t("landing.howItWorks.step2.title"),
|
||||
desc: t("landing.howItWorks.step2.desc"),
|
||||
color: "text-primary",
|
||||
},
|
||||
{
|
||||
step: "3",
|
||||
icon: Check,
|
||||
title: t("landing.howItWorks.step3.title"),
|
||||
desc: t("landing.howItWorks.step3.desc"),
|
||||
color: "text-emerald-500",
|
||||
},
|
||||
].map((s, i) => (
|
||||
<div key={i} className="flex flex-col items-center text-center">
|
||||
<div className={`flex size-16 items-center justify-center rounded-2xl bg-muted/50 mb-4 ${s.color}`}>
|
||||
<s.icon className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">{s.title}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">{s.desc}</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.excel.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.excel.description")}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 2 - Word */}
|
||||
<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-foreground mb-2">
|
||||
{t("features.word.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.word.description")}</p>
|
||||
{/* ── AI Translation Engine ── */}
|
||||
<section className="py-24 bg-muted/30" id="ai-engine">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/5 px-4 py-1.5 text-sm font-medium text-primary mb-6">
|
||||
<Brain className="h-4 w-4" />
|
||||
{t("landing.ai.badge")}
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.ai.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.ai.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 3 - PowerPoint */}
|
||||
<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" />
|
||||
{/* Three pillars */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto mb-16">
|
||||
{[
|
||||
{ icon: Zap, title: t("landing.ai.context.title"), desc: t("landing.ai.context.desc"), color: "text-blue-500" },
|
||||
{ icon: BookOpen, title: t("landing.ai.glossary.title"), desc: t("landing.ai.glossary.desc"), color: "text-primary" },
|
||||
{ icon: Eye, title: t("landing.ai.vision.title"), desc: t("landing.ai.vision.desc"), color: "text-emerald-500" },
|
||||
].map((feature, i) => (
|
||||
<div key={i} className="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-muted/50 ${feature.color}`}>
|
||||
<feature.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">{feature.title}</h3>
|
||||
<p className="text-muted-foreground text-sm">{feature.desc}</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.powerpoint.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t("features.powerpoint.description")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature 4 - AI */}
|
||||
<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" />
|
||||
{/* Comparison */}
|
||||
<div className="max-w-3xl mx-auto rounded-2xl border border-border bg-card p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("landing.ai.comparison.source")}</p>
|
||||
<p className="text-sm text-foreground font-medium italic">“{t("landing.ai.comparison.sourceText")}”</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.ai.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.ai.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 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 className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("landing.ai.comparison.google")}</p>
|
||||
<p className="text-sm text-destructive/80">“{t("landing.ai.comparison.googleText")}”</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-primary">{t("landing.ai.comparison.ai")}</p>
|
||||
<p className="text-sm text-emerald-600 font-medium">“{t("landing.ai.comparison.aiText")}”</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.speed.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.speed.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section className="py-24 bg-muted/30" id="pricing">
|
||||
{/* ── Why Us — Format Preservation ── */}
|
||||
<section className="py-24 bg-muted/30" id="why">
|
||||
<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 sm:text-4xl mb-4">
|
||||
{t("landing.why.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.why.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: t("landing.why.smartart.title"),
|
||||
desc: t("landing.why.smartart.desc"),
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: t("landing.why.toc.title"),
|
||||
desc: t("landing.why.toc.desc"),
|
||||
},
|
||||
{
|
||||
icon: Table2,
|
||||
title: t("landing.why.charts.title"),
|
||||
desc: t("landing.why.charts.desc"),
|
||||
},
|
||||
{
|
||||
icon: Presentation,
|
||||
title: t("landing.why.shapes.title"),
|
||||
desc: t("landing.why.shapes.desc"),
|
||||
},
|
||||
{
|
||||
icon: FileSpreadsheet,
|
||||
title: t("landing.why.headers.title"),
|
||||
desc: t("landing.why.headers.desc"),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: t("landing.why.languages.title"),
|
||||
desc: t("landing.why.languages.desc"),
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="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">
|
||||
<feature.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">{feature.title}</h3>
|
||||
<p className="text-muted-foreground">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Honest Pricing Comparison ── */}
|
||||
<section className="py-24 bg-background" id="pricing">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<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 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.pricing.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground">{t("pricing.subtitle")}</p>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.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">
|
||||
@@ -228,21 +357,24 @@ export function LandingPage() {
|
||||
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"
|
||||
!isYearly ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t("common.monthly")}
|
||||
{t("landing.pricing.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"
|
||||
isYearly ? "bg-primary text-primary-foreground shadow" : "text-muted-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")}
|
||||
{t("landing.pricing.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"
|
||||
)}>
|
||||
-20%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -250,140 +382,218 @@ export function LandingPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{/* Starter Plan */}
|
||||
{/* Starter */}
|
||||
<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-muted-foreground text-sm">
|
||||
{t("pricing.starter.description")}
|
||||
<h3 className="text-xl font-semibold">{t("landing.pricing.starter.title")}</h3>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("landing.pricing.starter.desc")}
|
||||
</p>
|
||||
<div className="mt-6 mb-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
{isYearly ? t("pricing.starter.priceYearly") : t("pricing.starter.priceMonthly")}
|
||||
<span className="text-4xl font-bold">
|
||||
€{isYearly ? prices.starter.yearly / 12 : prices.starter.monthly}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/{t("common.month")}</span>
|
||||
<span className="text-muted-foreground">{t("landing.pricing.perMonth")}</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">
|
||||
{[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}`)}
|
||||
{isYearly && (
|
||||
<span className="text-sm text-emerald-600 mb-4">€{prices.starter.yearly} {t("landing.pricing.billedYearly")}</span>
|
||||
)}
|
||||
<ul className="space-y-3 mb-8 flex-1 mt-4">
|
||||
{[
|
||||
t("landing.pricing.starter.f1"),
|
||||
t("landing.pricing.starter.f2"),
|
||||
t("landing.pricing.starter.f3"),
|
||||
t("landing.pricing.starter.f4"),
|
||||
].map((f, i) => (
|
||||
<li key={i} className="flex items-center text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/pricing?plan=starter">{t("common.signup")}</Link>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/pricing?plan=starter">{t("landing.pricing.starter.cta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<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")}
|
||||
{/* Pro — Popular */}
|
||||
<div className="relative rounded-2xl border-2 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-primary px-4 py-1 text-xs font-bold text-primary-foreground uppercase tracking-wide">
|
||||
{t("landing.pricing.pro.badge")}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
{t("pricing.pro.name")}
|
||||
</h3>
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
{t("pricing.pro.description")}
|
||||
<h3 className="text-xl font-semibold">{t("landing.pricing.pro.title")}</h3>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("landing.pricing.pro.desc")}
|
||||
</p>
|
||||
<div className="mt-6 mb-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
{isYearly ? t("pricing.pro.priceYearly") : t("pricing.pro.priceMonthly")}
|
||||
<span className="text-4xl font-bold">
|
||||
€{isYearly ? prices.pro.yearly / 12 : prices.pro.monthly}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/{t("common.month")}</span>
|
||||
<span className="text-muted-foreground">{t("landing.pricing.perMonth")}</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">
|
||||
{[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}`)}
|
||||
{isYearly && (
|
||||
<span className="text-sm text-emerald-600 mb-4">€{prices.pro.yearly} {t("landing.pricing.billedYearly")}</span>
|
||||
)}
|
||||
<ul className="space-y-3 mb-8 flex-1 mt-4">
|
||||
{[
|
||||
t("landing.pricing.pro.f1"),
|
||||
t("landing.pricing.pro.f2"),
|
||||
t("landing.pricing.pro.f3"),
|
||||
t("landing.pricing.pro.f4"),
|
||||
t("landing.pricing.pro.f5"),
|
||||
t("landing.pricing.pro.f6"),
|
||||
].map((f, i) => (
|
||||
<li key={i} className="flex items-center text-sm text-foreground font-medium">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
|
||||
>
|
||||
<Link href="/pricing?plan=pro">{t("common.tryPro")}</Link>
|
||||
<Button asChild className="w-full bg-primary text-primary-foreground font-bold hover:bg-primary/90">
|
||||
<Link href="/pricing?plan=pro">{t("landing.pricing.pro.cta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Business Plan */}
|
||||
{/* Business */}
|
||||
<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-muted-foreground text-sm">
|
||||
{t("pricing.business.description")}
|
||||
<h3 className="text-xl font-semibold">{t("landing.pricing.business.title")}</h3>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("landing.pricing.business.desc")}
|
||||
</p>
|
||||
<div className="mt-6 mb-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
{isYearly ? t("pricing.business.priceYearly") : t("pricing.business.priceMonthly")}
|
||||
<span className="text-4xl font-bold">
|
||||
€{isYearly ? prices.business.yearly / 12 : prices.business.monthly}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/{t("common.month")}</span>
|
||||
<span className="text-muted-foreground">{t("landing.pricing.perMonth")}</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">
|
||||
{[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}`)}
|
||||
{isYearly && (
|
||||
<span className="text-sm text-emerald-600 mb-4">€{prices.business.yearly} {t("landing.pricing.billedYearly")}</span>
|
||||
)}
|
||||
<ul className="space-y-3 mb-8 flex-1 mt-4">
|
||||
{[
|
||||
t("landing.pricing.business.f1"),
|
||||
t("landing.pricing.business.f2"),
|
||||
t("landing.pricing.business.f3"),
|
||||
t("landing.pricing.business.f4"),
|
||||
t("landing.pricing.business.f5"),
|
||||
t("landing.pricing.business.f6"),
|
||||
].map((f, i) => (
|
||||
<li key={i} className="flex items-center text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/pricing?plan=business">{t("common.tryPro")}</Link>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/pricing?plan=business">{t("landing.pricing.business.cta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Honest pricing callout */}
|
||||
<div className="mt-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/5 px-5 py-2.5 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<BadgeCheck className="h-5 w-5" />
|
||||
<span className="font-medium">{t("landing.pricing.honest")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
{/* ── Format Support ── */}
|
||||
<section className="py-24 bg-muted/30" id="formats">
|
||||
<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 sm:text-4xl mb-4">
|
||||
{t("landing.formats.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.formats.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{[
|
||||
{
|
||||
format: t("landing.formats.word"),
|
||||
items: [
|
||||
t("landing.formats.word.f1"),
|
||||
t("landing.formats.word.f2"),
|
||||
t("landing.formats.word.f3"),
|
||||
t("landing.formats.word.f4"),
|
||||
t("landing.formats.word.f5"),
|
||||
t("landing.formats.word.f6"),
|
||||
t("landing.formats.word.f7"),
|
||||
],
|
||||
},
|
||||
{
|
||||
format: t("landing.formats.excel"),
|
||||
items: [
|
||||
t("landing.formats.excel.f1"),
|
||||
t("landing.formats.excel.f2"),
|
||||
t("landing.formats.excel.f3"),
|
||||
t("landing.formats.excel.f4"),
|
||||
t("landing.formats.excel.f5"),
|
||||
],
|
||||
},
|
||||
{
|
||||
format: t("landing.formats.powerpoint"),
|
||||
items: [
|
||||
t("landing.formats.powerpoint.f1"),
|
||||
t("landing.formats.powerpoint.f2"),
|
||||
t("landing.formats.powerpoint.f3"),
|
||||
t("landing.formats.powerpoint.f4"),
|
||||
t("landing.formats.powerpoint.f5"),
|
||||
],
|
||||
},
|
||||
{
|
||||
format: t("landing.formats.pdf"),
|
||||
items: [
|
||||
t("landing.formats.pdf.f1"),
|
||||
t("landing.formats.pdf.f2"),
|
||||
t("landing.formats.pdf.f3"),
|
||||
t("landing.formats.pdf.f4"),
|
||||
t("landing.formats.pdf.f5"),
|
||||
],
|
||||
},
|
||||
].map((fmt, i) => (
|
||||
<div key={i} className="rounded-2xl border border-border bg-card p-8">
|
||||
<h3 className="text-xl font-bold mb-4">{fmt.format}</h3>
|
||||
<ul className="space-y-2">
|
||||
{fmt.items.map((item, j) => (
|
||||
<li key={j} className="flex items-center text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── CTA ── */}
|
||||
<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-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-foreground mb-6">
|
||||
{t("cta.title")}
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
{t("landing.cta.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-8 max-w-2xl mx-auto">
|
||||
{t("cta.subtitle")}
|
||||
{t("landing.cta.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90 text-base px-8"
|
||||
>
|
||||
<Link href="/auth/register">{t("cta.button")}</Link>
|
||||
<Link href="/auth/register">
|
||||
{t("landing.cta.button")}
|
||||
<ArrowRight className="ms-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
{/* ── Footer ── */}
|
||||
<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">
|
||||
@@ -396,17 +606,13 @@ export function LandingPage() {
|
||||
</span>
|
||||
</div>
|
||||
<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-foreground transition-colors" href="#">
|
||||
{t("footer.terms")}
|
||||
</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">
|
||||
{t("footer.contact")}
|
||||
</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.privacy")}</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.terms")}</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.contact")}</a>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Office Translator
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">{t("footer.copyright")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useCallback, memo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Upload,
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
Crown,
|
||||
LogOut,
|
||||
BookOpen,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Translate",
|
||||
href: "/",
|
||||
icon: Upload,
|
||||
description: "Translate documents",
|
||||
},
|
||||
{
|
||||
name: "Context",
|
||||
href: "/settings/context",
|
||||
icon: BookOpen,
|
||||
description: "Configure AI instructions & glossary",
|
||||
},
|
||||
];
|
||||
|
||||
const planColors: Record<string, string> = {
|
||||
free: "bg-zinc-600",
|
||||
starter: "bg-blue-500",
|
||||
pro: "bg-teal-500",
|
||||
business: "bg-purple-500",
|
||||
enterprise: "bg-amber-500",
|
||||
};
|
||||
|
||||
// Memoized NavItem for performance
|
||||
const NavItem = memo(function NavItem({
|
||||
item,
|
||||
isActive
|
||||
}: {
|
||||
item: typeof navigation[0];
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
// Listen for storage changes
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === "user") {
|
||||
setUser(e.newValue ? JSON.parse(e.newValue) : null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
setUser(null);
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
// Prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">文A</div>
|
||||
<span className="text-lg font-semibold text-white">Office Translator</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white">Office Translator</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* User Section */}
|
||||
{user && (
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||
<p className="px-3 mb-2 text-xs font-medium text-zinc-600 uppercase tracking-wider">Account</p>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/dashboard"
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>View your usage and settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/settings/subscription"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/settings/subscription"
|
||||
? "bg-violet-500/10 text-violet-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Crown className="h-5 w-5" />
|
||||
<span>Mon abonnement</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Gérer votre forfait et votre usage</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{user.plan === "free" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/pricing"
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "text-amber-400/70 hover:bg-zinc-800 hover:text-amber-400"
|
||||
)}
|
||||
>
|
||||
<Zap className="h-5 w-5" />
|
||||
<span>Passer Pro</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Voir tous les forfaits disponibles</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User section at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/dashboard" className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-teal-500 to-teal-600 text-white text-sm font-medium shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium text-white truncate">{user.name}</span>
|
||||
<Badge className={cn("text-xs mt-0.5 w-fit", planColors[user.plan] || "bg-zinc-600")}>
|
||||
{{ free: "Gratuit", starter: "Starter", pro: "Pro", business: "Business", enterprise: "Entreprise" }[user.plan] ?? user.plan}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Link href="/auth/login" className="block">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white text-sm font-medium transition-colors">
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign In
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/auth/register" className="block">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-teal-500 hover:bg-teal-600 text-white text-sm font-medium transition-colors">
|
||||
Get Started Free
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<span className="mr-1 flex-shrink-0">
|
||||
<span className="me-1 flex-shrink-0">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -126,7 +126,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
<span className="flex items-center gap-1">
|
||||
{children}
|
||||
{displayCount && (
|
||||
<span className="ml-1 bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||
<span className="ms-1 bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -137,7 +137,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="ml-1 flex-shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors p-0.5"
|
||||
className="ms-1 flex-shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors p-0.5"
|
||||
aria-label="Remove badge"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -118,7 +118,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
className="animate-spin -ms-1 me-2 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -155,7 +155,7 @@ function DropdownMenuLabel({
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:ps-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -80,8 +80,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
"outline-none focus:outline-none",
|
||||
"read-only:cursor-default read-only:bg-surface/50",
|
||||
variants[variant],
|
||||
leftIcon && "pl-10",
|
||||
rightIcon && "pr-10",
|
||||
leftIcon && "ps-10",
|
||||
rightIcon && "pe-10",
|
||||
(leftIcon && rightIcon) && "px-10",
|
||||
error && "text-destructive",
|
||||
className
|
||||
@@ -264,7 +264,7 @@ export const SearchInput = React.forwardRef<
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"pl-10 pr-10",
|
||||
"ps-10 pe-10",
|
||||
focused && "shadow-lg shadow-primary/10",
|
||||
className
|
||||
)}
|
||||
@@ -331,7 +331,7 @@ export const FileInput = React.forwardRef<
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className={cn(
|
||||
"file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer",
|
||||
"file:me-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer",
|
||||
dragActive && "border-primary bg-primary/5",
|
||||
className
|
||||
)}
|
||||
@@ -344,7 +344,7 @@ export const FileInput = React.forwardRef<
|
||||
/>
|
||||
|
||||
{fileName && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pe-3">
|
||||
<span className="text-xs text-muted-foreground truncate max-w-32">
|
||||
{fileName}
|
||||
</span>
|
||||
|
||||
@@ -14,6 +14,17 @@ import { useI18n, type Locale } from "@/lib/i18n";
|
||||
const languages: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: "en", label: "English", flag: "🇬🇧" },
|
||||
{ value: "fr", label: "Français", flag: "🇫🇷" },
|
||||
{ value: "es", label: "Español", flag: "🇪🇸" },
|
||||
{ value: "de", label: "Deutsch", flag: "🇩🇪" },
|
||||
{ value: "pt", label: "Português", flag: "🇧🇷" },
|
||||
{ value: "it", label: "Italiano", flag: "🇮🇹" },
|
||||
{ value: "nl", label: "Nederlands", flag: "🇳🇱" },
|
||||
{ value: "ru", label: "Русский", flag: "🇷🇺" },
|
||||
{ value: "ja", label: "日本語", flag: "🇯🇵" },
|
||||
{ value: "ko", label: "한국어", flag: "🇰🇷" },
|
||||
{ value: "zh", label: "中文", flag: "🇨🇳" },
|
||||
{ value: "ar", label: "العربية", flag: "🇸🇦" },
|
||||
{ value: "fa", label: "فارسی", flag: "🇮🇷" },
|
||||
];
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
@@ -43,7 +54,7 @@ export function LanguageSwitcher({ variant = "select" }: LanguageSwitcherProps)
|
||||
|
||||
return (
|
||||
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}>
|
||||
<SelectTrigger className="w-[130px] h-9 bg-transparent border-border-dark hover:bg-surface-dark">
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
@@ -52,12 +63,11 @@ export function LanguageSwitcher({ variant = "select" }: LanguageSwitcherProps)
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-dark border-border-dark">
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem
|
||||
key={lang.value}
|
||||
value={lang.value}
|
||||
className="hover:bg-primary/10 focus:bg-primary/10"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function ModelCombobox({
|
||||
<span className="text-muted-foreground">ctx: {(model.context_length / 1000).toFixed(0)}k</span>
|
||||
)}
|
||||
</div>
|
||||
{value === model.id && <Check className="size-3.5 text-primary flex-shrink-0 ml-2" />}
|
||||
{value === model.id && <Check className="size-3.5 text-primary flex-shrink-0 ms-2" />}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucid
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const notificationVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg transition-all duration-300 ease-out",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -109,7 +109,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -74,7 +74,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"h-10 px-2 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -89,7 +89,7 @@ const TableCell = React.forwardRef<
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:animate-out",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pe-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
Reference in New Issue
Block a user