feat: Add complete monetization system

Backend:
- User authentication with JWT tokens (auth_service.py)
- Subscription plans: Free, Starter (), Pro (), Business (), Enterprise
- Stripe integration for payments (payment_service.py)
- Usage tracking and quotas
- Credit packages for pay-per-use
- Plan-based provider restrictions

Frontend:
- Landing page with hero, features, pricing preview (landing-sections.tsx)
- Pricing page with all plans and credit packages (/pricing)
- User dashboard with usage stats (/dashboard)
- Login/Register pages with validation (/auth/login, /auth/register)
- Ollama self-hosting setup guide (/ollama-setup)
- Updated sidebar with user section and plan badge

Monetization strategy:
- Freemium: 3 docs/day, Ollama only
- Starter: 50 docs/month, Google Translate
- Pro: 200 docs/month, all providers, API access
- Business: 1000 docs/month, team management
- Enterprise: Custom pricing, SLA

Self-hosted option:
- Free unlimited usage with own Ollama server
- Complete privacy (data never leaves machine)
- Step-by-step setup guide included
This commit is contained in:
2025-11-30 21:11:51 +01:00
parent 29178a75a5
commit fcabe882cd
18 changed files with 3142 additions and 31 deletions

View File

@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("http://localhost:8000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Login failed");
}
// Store tokens
localStorage.setItem("token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
localStorage.setItem("user", JSON.stringify(data.user));
// Redirect
router.push(redirect);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
A
</div>
<span className="text-2xl font-semibold text-white">Translate Co.</span>
</Link>
</div>
{/* Card */}
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">Welcome back</h1>
<p className="text-zinc-400">Sign in to continue translating</p>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-zinc-300">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-zinc-300">Password</Label>
<Link href="/auth/forgot-password" className="text-sm text-teal-400 hover:text-teal-300">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Sign In
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 text-center text-sm text-zinc-400">
Don&apos;t have an account?{" "}
<Link
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
className="text-teal-400 hover:text-teal-300"
>
Sign up for free
</Link>
</div>
</div>
{/* Features reminder */}
<div className="mt-8 text-center">
<p className="text-sm text-zinc-500 mb-3">Start with our free plan:</p>
<div className="flex flex-wrap justify-center gap-2">
{["3 docs/day", "10 pages/doc", "Ollama support"].map((feature) => (
<span
key={feature}
className="px-3 py-1 rounded-full bg-zinc-800 text-zinc-400 text-xs"
>
{feature}
</span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function RegisterPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const res = await fetch("http://localhost:8000/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Registration failed");
}
// Store tokens
localStorage.setItem("token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
localStorage.setItem("user", JSON.stringify(data.user));
// Redirect
router.push(redirect);
} catch (err: any) {
setError(err.message || "Registration failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
A
</div>
<span className="text-2xl font-semibold text-white">Translate Co.</span>
</Link>
</div>
{/* Card */}
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">Create an account</h1>
<p className="text-zinc-400">Start translating documents for free</p>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-zinc-300">Full Name</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-zinc-300">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-zinc-300">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-zinc-300">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="confirmPassword"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Create Account
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 text-center text-sm text-zinc-400">
Already have an account?{" "}
<Link
href={`/auth/login${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
className="text-teal-400 hover:text-teal-300"
>
Sign in
</Link>
</div>
<div className="mt-6 text-center text-xs text-zinc-500">
By creating an account, you agree to our{" "}
<Link href="/terms" className="text-zinc-400 hover:text-zinc-300">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-zinc-400 hover:text-zinc-300">
Privacy Policy
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,363 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
FileText,
CreditCard,
Settings,
LogOut,
ChevronRight,
Zap,
TrendingUp,
Clock,
Check,
ExternalLink,
Crown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
interface User {
id: string;
email: string;
name: string;
plan: string;
subscription_status: string;
docs_translated_this_month: number;
pages_translated_this_month: number;
extra_credits: number;
plan_limits: {
docs_per_month: number;
max_pages_per_doc: number;
features: string[];
providers: string[];
};
}
interface UsageStats {
docs_used: number;
docs_limit: number;
docs_remaining: number;
pages_used: number;
extra_credits: number;
max_pages_per_doc: number;
allowed_providers: string[];
}
export default function DashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [usage, setUsage] = useState<UsageStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
router.push("/auth/login?redirect=/dashboard");
return;
}
fetchUserData(token);
}, [router]);
const fetchUserData = async (token: string) => {
try {
const [userRes, usageRes] = await Promise.all([
fetch("http://localhost:8000/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
}),
fetch("http://localhost:8000/api/auth/usage", {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (!userRes.ok) {
throw new Error("Session expired");
}
const userData = await userRes.json();
const usageData = await usageRes.json();
setUser(userData);
setUsage(usageData);
} catch (error) {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/auth/login?redirect=/dashboard");
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
router.push("/");
};
const handleUpgrade = () => {
router.push("/pricing");
};
const handleManageBilling = async () => {
const token = localStorage.getItem("token");
try {
const res = await fetch("http://localhost:8000/api/auth/billing-portal", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data.url) {
window.open(data.url, "_blank");
}
} catch (error) {
console.error("Failed to open billing portal");
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#262626] flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-teal-500" />
</div>
);
}
if (!user || !usage) {
return null;
}
const docsPercentage = usage.docs_limit > 0
? Math.min(100, (usage.docs_used / usage.docs_limit) * 100)
: 0;
const planColors: Record<string, string> = {
free: "bg-zinc-500",
starter: "bg-blue-500",
pro: "bg-teal-500",
business: "bg-purple-500",
enterprise: "bg-amber-500",
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626]">
{/* Header */}
<header className="border-b border-zinc-800 bg-[#1a1a1a]/80 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="flex items-center gap-3">
<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">Translate Co.</span>
</Link>
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800">
<FileText className="h-4 w-4 mr-2" />
Translate
</Button>
</Link>
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-teal-600 flex items-center justify-center text-white text-sm font-medium">
{user.name.charAt(0).toUpperCase()}
</div>
<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>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Welcome Section */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white">Welcome back, {user.name.split(" ")[0]}!</h1>
<p className="text-zinc-400 mt-1">Here's an overview of your translation usage</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* Current Plan */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Current Plan</span>
<Badge className={cn("text-white", planColors[user.plan])}>
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
</Badge>
</div>
<div className="flex items-center gap-2">
<Crown className="h-5 w-5 text-amber-400" />
<span className="text-2xl font-bold text-white capitalize">{user.plan}</span>
</div>
{user.plan !== "enterprise" && (
<Button
onClick={handleUpgrade}
size="sm"
className="mt-4 w-full bg-teal-500 hover:bg-teal-600 text-white"
>
Upgrade Plan
</Button>
)}
</div>
{/* Documents Used */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Documents This Month</span>
<FileText className="h-4 w-4 text-zinc-500" />
</div>
<div className="text-2xl font-bold text-white mb-2">
{usage.docs_used} / {usage.docs_limit === -1 ? "∞" : usage.docs_limit}
</div>
<Progress value={docsPercentage} className="h-2 bg-zinc-800" />
<p className="text-xs text-zinc-500 mt-2">
{usage.docs_remaining === -1
? "Unlimited"
: `${usage.docs_remaining} remaining`}
</p>
</div>
{/* Pages Translated */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Pages Translated</span>
<TrendingUp className="h-4 w-4 text-teal-400" />
</div>
<div className="text-2xl font-bold text-white">
{usage.pages_used}
</div>
<p className="text-xs text-zinc-500 mt-2">
Max {usage.max_pages_per_doc === -1 ? "unlimited" : usage.max_pages_per_doc} pages/doc
</p>
</div>
{/* Extra Credits */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Extra Credits</span>
<Zap className="h-4 w-4 text-amber-400" />
</div>
<div className="text-2xl font-bold text-white">
{usage.extra_credits}
</div>
<Link href="/pricing#credits">
<Button variant="outline" size="sm" className="mt-4 w-full border-zinc-700 text-zinc-300 hover:bg-zinc-800">
Buy Credits
</Button>
</Link>
</div>
</div>
{/* Features & Actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Available Features */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Your Plan Features</h2>
<ul className="space-y-3">
{user.plan_limits.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
<span className="text-zinc-300">{feature}</span>
</li>
))}
</ul>
</div>
{/* Quick Actions */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-2">
<Link href="/">
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-teal-400" />
<span className="text-white">Translate a Document</span>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
</button>
</Link>
<Link href="/settings/services">
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
<div className="flex items-center gap-3">
<Settings className="h-5 w-5 text-blue-400" />
<span className="text-white">Configure Providers</span>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
</button>
</Link>
{user.plan !== "free" && (
<button
onClick={handleManageBilling}
className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors"
>
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-purple-400" />
<span className="text-white">Manage Billing</span>
</div>
<ExternalLink className="h-4 w-4 text-zinc-500" />
</button>
)}
<Link href="/pricing">
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
<div className="flex items-center gap-3">
<Crown className="h-5 w-5 text-amber-400" />
<span className="text-white">View Plans & Pricing</span>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
</button>
</Link>
</div>
</div>
</div>
{/* Available Providers */}
<div className="mt-6 rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Available Translation Providers</h2>
<div className="flex flex-wrap gap-2">
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
const isAvailable = usage.allowed_providers.includes(provider);
return (
<Badge
key={provider}
variant="outline"
className={cn(
"capitalize",
isAvailable
? "border-teal-500/50 text-teal-400 bg-teal-500/10"
: "border-zinc-700 text-zinc-500"
)}
>
{isAvailable && <Check className="h-3 w-3 mr-1" />}
{provider}
</Badge>
);
})}
</div>
{user.plan === "free" && (
<p className="text-sm text-zinc-500 mt-4">
<Link href="/pricing" className="text-teal-400 hover:text-teal-300">
Upgrade your plan
</Link>{" "}
to access more translation providers including Google, DeepL, and OpenAI.
</p>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,364 @@
"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>
);
}

View File

@@ -5,6 +5,12 @@ import { useTranslationStore } from "@/lib/store";
import { Badge } from "@/components/ui/badge";
import { Settings } from "lucide-react";
import Link from "next/link";
import {
LandingHero,
FeaturesSection,
PricingPreview,
SelfHostCTA
} from "@/components/landing-sections";
export default function Home() {
const { settings } = useTranslationStore();
@@ -15,35 +21,71 @@ export default function Home() {
deepl: "DeepL",
libre: "LibreTranslate",
webllm: "WebLLM",
openai: "OpenAI",
};
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-white">Translate Documents</h1>
<p className="text-zinc-400 mt-1">
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
</p>
</div>
{/* Current Configuration Badge */}
<Link href="/settings/services" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800/50 border border-zinc-700 hover:bg-zinc-800 transition-colors">
<Settings className="h-4 w-4 text-zinc-400" />
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
{providerNames[settings.defaultProvider]}
</Badge>
{settings.defaultProvider === "ollama" && settings.ollamaModel && (
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs">
{settings.ollamaModel}
</Badge>
)}
<div className="space-y-0 -m-8">
{/* Hero Section */}
<LandingHero />
{/* Upload Section */}
<div id="upload" className="px-8 py-12 bg-zinc-900/30">
<div className="max-w-6xl mx-auto">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Translate Your Document</h2>
<p className="text-zinc-400 mt-1">
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
</p>
</div>
{/* Current Configuration Badge */}
<Link href="/settings/services" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800/50 border border-zinc-700 hover:bg-zinc-800 transition-colors">
<Settings className="h-4 w-4 text-zinc-400" />
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
{providerNames[settings.defaultProvider]}
</Badge>
{settings.defaultProvider === "ollama" && settings.ollamaModel && (
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs">
{settings.ollamaModel}
</Badge>
)}
</div>
</Link>
</div>
</Link>
<FileUploader />
</div>
</div>
<FileUploader />
{/* Features Section */}
<FeaturesSection />
{/* Pricing Preview */}
<PricingPreview />
{/* Self-Host CTA */}
<SelfHostCTA />
{/* Footer */}
<footer className="border-t border-zinc-800 py-8 px-8">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-500 text-white font-bold text-sm">
A
</div>
<span className="text-sm text-zinc-400">© 2024 Translate Co. All rights reserved.</span>
</div>
<div className="flex items-center gap-6 text-sm text-zinc-500">
<Link href="/pricing" className="hover:text-zinc-300">Pricing</Link>
<Link href="/ollama-setup" className="hover:text-zinc-300">Self-Host</Link>
<Link href="/terms" className="hover:text-zinc-300">Terms</Link>
<Link href="/privacy" className="hover:text-zinc-300">Privacy</Link>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,421 @@
"use client";
import { useState, useEffect } from "react";
import { Check, Zap, Building2, Crown, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface Plan {
id: string;
name: string;
price_monthly: number;
price_yearly: number;
features: string[];
docs_per_month: number;
max_pages_per_doc: number;
providers: string[];
popular?: boolean;
}
interface CreditPackage {
credits: number;
price: number;
price_per_credit: number;
popular?: boolean;
}
const planIcons: Record<string, any> = {
free: Sparkles,
starter: Zap,
pro: Crown,
business: Building2,
enterprise: Building2,
};
export default function PricingPage() {
const [isYearly, setIsYearly] = useState(false);
const [plans, setPlans] = useState<Plan[]>([]);
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPlans();
}, []);
const fetchPlans = async () => {
try {
const res = await fetch("http://localhost:8000/api/auth/plans");
const data = await res.json();
setPlans(data.plans || []);
setCreditPackages(data.credit_packages || []);
} catch (error) {
// Use default plans if API fails
setPlans([
{
id: "free",
name: "Free",
price_monthly: 0,
price_yearly: 0,
features: [
"3 documents per day",
"Up to 10 pages per document",
"Ollama (self-hosted) only",
"Basic support via community",
],
docs_per_month: 3,
max_pages_per_doc: 10,
providers: ["ollama"],
},
{
id: "starter",
name: "Starter",
price_monthly: 9,
price_yearly: 90,
features: [
"50 documents per month",
"Up to 50 pages per document",
"Google Translate included",
"LibreTranslate included",
"Email support",
],
docs_per_month: 50,
max_pages_per_doc: 50,
providers: ["ollama", "google", "libre"],
},
{
id: "pro",
name: "Pro",
price_monthly: 29,
price_yearly: 290,
features: [
"200 documents per month",
"Up to 200 pages per document",
"All translation providers",
"DeepL & OpenAI included",
"API access (1000 calls/month)",
"Priority email support",
],
docs_per_month: 200,
max_pages_per_doc: 200,
providers: ["ollama", "google", "deepl", "openai", "libre"],
popular: true,
},
{
id: "business",
name: "Business",
price_monthly: 79,
price_yearly: 790,
features: [
"1000 documents per month",
"Up to 500 pages per document",
"All translation providers",
"Azure Translator included",
"Unlimited API access",
"Priority processing queue",
"Dedicated support",
"Team management (up to 5 users)",
],
docs_per_month: 1000,
max_pages_per_doc: 500,
providers: ["ollama", "google", "deepl", "openai", "libre", "azure"],
},
{
id: "enterprise",
name: "Enterprise",
price_monthly: -1,
price_yearly: -1,
features: [
"Unlimited documents",
"Unlimited pages",
"Custom integrations",
"On-premise deployment",
"SLA guarantee",
"24/7 dedicated support",
"Custom AI models",
"White-label option",
],
docs_per_month: -1,
max_pages_per_doc: -1,
providers: ["all"],
},
]);
setCreditPackages([
{ credits: 50, price: 5, price_per_credit: 0.1 },
{ credits: 100, price: 9, price_per_credit: 0.09, popular: true },
{ credits: 250, price: 20, price_per_credit: 0.08 },
{ credits: 500, price: 35, price_per_credit: 0.07 },
{ credits: 1000, price: 60, price_per_credit: 0.06 },
]);
} finally {
setLoading(false);
}
};
const handleSubscribe = async (planId: string) => {
// Check if user is logged in
const token = localStorage.getItem("token");
if (!token) {
window.location.href = "/auth/login?redirect=/pricing&plan=" + planId;
return;
}
// Create checkout session
try {
const res = await fetch("http://localhost:8000/api/auth/checkout/subscription", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
plan: planId,
billing_period: isYearly ? "yearly" : "monthly",
}),
});
const data = await res.json();
if (data.url) {
window.location.href = data.url;
} else if (data.demo_mode) {
alert("Upgraded to " + planId + " (demo mode)");
window.location.href = "/dashboard";
}
} catch (error) {
console.error("Checkout error:", error);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<Badge className="mb-4 bg-teal-500/20 text-teal-400 border-teal-500/30">
Pricing
</Badge>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Simple, Transparent Pricing
</h1>
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
Choose the perfect plan for your translation needs. Start free and scale as you grow.
</p>
{/* Billing Toggle */}
<div className="mt-8 flex items-center justify-center gap-4">
<span className={cn("text-sm", !isYearly ? "text-white" : "text-zinc-500")}>
Monthly
</span>
<button
onClick={() => setIsYearly(!isYearly)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isYearly ? "bg-teal-500" : "bg-zinc-700"
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
isYearly ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
<span className={cn("text-sm", isYearly ? "text-white" : "text-zinc-500")}>
Yearly
<Badge className="ml-2 bg-green-500/20 text-green-400 border-green-500/30 text-xs">
Save 17%
</Badge>
</span>
</div>
</div>
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
{plans.slice(0, 4).map((plan) => {
const Icon = planIcons[plan.id] || Sparkles;
const price = isYearly ? plan.price_yearly : plan.price_monthly;
const isEnterprise = plan.id === "enterprise";
const isPro = plan.popular;
return (
<div
key={plan.id}
className={cn(
"relative rounded-2xl border p-6 flex flex-col",
isPro
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
: "border-zinc-800 bg-zinc-900/50"
)}
>
{isPro && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
Most Popular
</Badge>
)}
<div className="flex items-center gap-3 mb-4">
<div
className={cn(
"p-2 rounded-lg",
isPro ? "bg-teal-500/20" : "bg-zinc-800"
)}
>
<Icon className={cn("h-5 w-5", isPro ? "text-teal-400" : "text-zinc-400")} />
</div>
<h3 className="text-xl font-semibold text-white">{plan.name}</h3>
</div>
<div className="mb-6">
{isEnterprise || price < 0 ? (
<div className="text-3xl font-bold text-white">Custom</div>
) : (
<>
<span className="text-4xl font-bold text-white">
${isYearly ? Math.round(price / 12) : price}
</span>
<span className="text-zinc-500">/month</span>
{isYearly && price > 0 && (
<div className="text-sm text-zinc-500">
${price} billed yearly
</div>
)}
</>
)}
</div>
<ul className="space-y-3 mb-6 flex-1">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
<span className="text-sm text-zinc-300">{feature}</span>
</li>
))}
</ul>
<Button
onClick={() => handleSubscribe(plan.id)}
className={cn(
"w-full",
isPro
? "bg-teal-500 hover:bg-teal-600 text-white"
: "bg-zinc-800 hover:bg-zinc-700 text-white"
)}
>
{plan.id === "free"
? "Get Started"
: isEnterprise
? "Contact Sales"
: "Subscribe"}
</Button>
</div>
);
})}
</div>
{/* Enterprise Section */}
{plans.find((p) => p.id === "enterprise") && (
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-purple-500/10 to-teal-500/10 p-8 mb-16">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<h3 className="text-2xl font-bold text-white mb-2">
Need Enterprise Features?
</h3>
<p className="text-zinc-400 max-w-xl">
Get unlimited translations, custom integrations, on-premise deployment,
dedicated support, and SLA guarantees. Perfect for large organizations.
</p>
</div>
<Button
size="lg"
className="bg-gradient-to-r from-purple-500 to-teal-500 hover:from-purple-600 hover:to-teal-600 text-white whitespace-nowrap"
>
Contact Sales
</Button>
</div>
</div>
)}
{/* Credit Packages */}
<div className="mb-16">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Need Extra Pages?</h2>
<p className="text-zinc-400">
Buy credit packages to translate more pages. Credits never expire.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{creditPackages.map((pkg, idx) => (
<div
key={idx}
className={cn(
"rounded-xl border p-4 text-center",
pkg.popular
? "border-teal-500 bg-teal-500/10"
: "border-zinc-800 bg-zinc-900/50"
)}
>
{pkg.popular && (
<Badge className="mb-2 bg-teal-500/20 text-teal-400 border-teal-500/30 text-xs">
Best Value
</Badge>
)}
<div className="text-2xl font-bold text-white">{pkg.credits}</div>
<div className="text-sm text-zinc-500 mb-2">pages</div>
<div className="text-xl font-semibold text-white">${pkg.price}</div>
<div className="text-xs text-zinc-500">
${pkg.price_per_credit.toFixed(2)}/page
</div>
<Button
size="sm"
variant="outline"
className="mt-3 w-full border-zinc-700 hover:bg-zinc-800"
>
Buy
</Button>
</div>
))}
</div>
</div>
{/* FAQ Section */}
<div className="max-w-3xl mx-auto">
<h2 className="text-2xl font-bold text-white text-center mb-8">
Frequently Asked Questions
</h2>
<div className="space-y-4">
{[
{
q: "Can I use my own Ollama instance?",
a: "Yes! The Free plan lets you connect your own Ollama server for unlimited translations. You just need to configure your Ollama endpoint in the settings.",
},
{
q: "What happens if I exceed my monthly limit?",
a: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages.",
},
{
q: "Can I cancel my subscription anytime?",
a: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period.",
},
{
q: "Do credits expire?",
a: "No, purchased credits never expire and can be used anytime.",
},
{
q: "What file formats are supported?",
a: "We support Microsoft Office documents: Word (.docx), Excel (.xlsx), and PowerPoint (.pptx). The formatting is fully preserved during translation.",
},
].map((faq, idx) => (
<div key={idx} className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
<h3 className="font-medium text-white mb-2">{faq.q}</h3>
<p className="text-sm text-zinc-400">{faq.a}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import {
ArrowRight,
Check,
FileText,
Globe2,
Zap,
Shield,
Server,
Sparkles,
FileSpreadsheet,
Presentation
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface User {
name: string;
plan: string;
}
export function LandingHero() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const storedUser = localStorage.getItem("user");
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch {
setUser(null);
}
}
}, []);
return (
<div className="relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/10 via-transparent to-purple-500/10" />
{/* Hero content */}
<div className="relative px-4 py-16 sm:py-24">
<div className="text-center max-w-4xl mx-auto">
<Badge className="mb-6 bg-teal-500/20 text-teal-400 border-teal-500/30">
<Sparkles className="h-3 w-3 mr-1" />
AI-Powered Document Translation
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Translate Documents{" "}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-teal-400 to-cyan-400">
Instantly
</span>
</h1>
<p className="text-xl text-zinc-400 mb-8 max-w-2xl mx-auto">
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
all formatting, styles, and layouts. Powered by AI.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
{user ? (
<Link href="#upload">
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
Start Translating
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
<>
<Link href="/auth/register">
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
Get Started Free
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Link href="/pricing">
<Button size="lg" variant="outline" className="border-zinc-700 text-white hover:bg-zinc-800">
View Pricing
</Button>
</Link>
</>
)}
</div>
{/* Supported formats */}
<div className="flex flex-wrap justify-center gap-4">
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm text-zinc-300">Word (.docx)</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
<FileSpreadsheet className="h-4 w-4 text-green-400" />
<span className="text-sm text-zinc-300">Excel (.xlsx)</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
<Presentation className="h-4 w-4 text-orange-400" />
<span className="text-sm text-zinc-300">PowerPoint (.pptx)</span>
</div>
</div>
</div>
</div>
</div>
);
}
export function FeaturesSection() {
const features = [
{
icon: Globe2,
title: "100+ Languages",
description: "Translate between any language pair with high accuracy using AI models",
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: Server,
title: "Self-Host Option",
description: "Use your own Ollama server for complete privacy and unlimited usage",
color: "text-orange-400",
},
{
icon: Sparkles,
title: "Multiple AI Providers",
description: "Choose from Google, DeepL, OpenAI, or local Ollama models",
color: "text-teal-400",
},
];
return (
<div className="py-16 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-white mb-4">
Everything You Need for Document Translation
</h2>
<p className="text-zinc-400 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="p-6 rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
>
<Icon className={cn("h-8 w-8 mb-4", feature.color)} />
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
<p className="text-zinc-400 text-sm">{feature.description}</p>
</div>
);
})}
</div>
</div>
</div>
);
}
export function PricingPreview() {
const plans = [
{
name: "Free",
price: "$0",
description: "Perfect for trying out",
features: ["3 documents/day", "10 pages/doc", "Ollama only"],
cta: "Get Started",
href: "/auth/register",
},
{
name: "Pro",
price: "$29",
period: "/month",
description: "For professionals",
features: ["200 documents/month", "All providers", "API access", "Priority support"],
cta: "Start Free Trial",
href: "/pricing",
popular: true,
},
{
name: "Business",
price: "$79",
period: "/month",
description: "For teams",
features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"],
cta: "Contact Sales",
href: "/pricing",
},
];
return (
<div className="py-16 px-4 bg-zinc-900/50">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-white mb-4">
Simple, Transparent Pricing
</h2>
<p className="text-zinc-400">
Start free, upgrade when you need more.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{plans.map((plan) => (
<div
key={plan.name}
className={cn(
"relative p-6 rounded-xl border",
plan.popular
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
: "border-zinc-800 bg-zinc-900/50"
)}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
Most Popular
</Badge>
)}
<h3 className="text-xl font-semibold text-white mb-1">{plan.name}</h3>
<p className="text-sm text-zinc-400 mb-4">{plan.description}</p>
<div className="mb-6">
<span className="text-3xl font-bold text-white">{plan.price}</span>
{plan.period && <span className="text-zinc-500">{plan.period}</span>}
</div>
<ul className="space-y-2 mb-6">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-zinc-300">
<Check className="h-4 w-4 text-teal-400" />
{feature}
</li>
))}
</ul>
<Link href={plan.href}>
<Button
className={cn(
"w-full",
plan.popular
? "bg-teal-500 hover:bg-teal-600 text-white"
: "bg-zinc-800 hover:bg-zinc-700 text-white"
)}
>
{plan.cta}
</Button>
</Link>
</div>
))}
</div>
<div className="text-center mt-8">
<Link href="/pricing" className="text-teal-400 hover:text-teal-300 text-sm">
View all plans and features
</Link>
</div>
</div>
</div>
);
}
export function SelfHostCTA() {
return (
<div className="py-16 px-4">
<div className="max-w-4xl mx-auto">
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-orange-500/10 to-amber-500/10 p-8 text-center">
<Server className="h-12 w-12 text-orange-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-white mb-2">
Prefer Self-Hosting?
</h2>
<p className="text-zinc-400 mb-6 max-w-xl mx-auto">
Run your own Ollama server for complete privacy and unlimited translations.
No API costs, no quotas, your data stays on your machine.
</p>
<Link href="/ollama-setup">
<Button className="bg-orange-500 hover:bg-orange-600 text-white">
Setup Ollama
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
Settings,
@@ -9,6 +10,11 @@ import {
BookText,
Upload,
Shield,
CreditCard,
LayoutDashboard,
LogIn,
Crown,
LogOut,
} from "lucide-react";
import {
Tooltip,
@@ -16,6 +22,14 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
interface User {
id: string;
name: string;
email: string;
plan: string;
}
const navigation = [
{
@@ -55,6 +69,35 @@ const adminNavigation = [
export function Sidebar() {
const pathname = usePathname();
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// Check for user in localStorage
const storedUser = localStorage.getItem("user");
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch {
setUser(null);
}
}
}, []);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
setUser(null);
window.location.href = "/";
};
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",
};
return (
<TooltipProvider>
@@ -95,6 +138,53 @@ export function Sidebar() {
</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="/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-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
)}
>
<Crown className="h-5 w-5" />
<span>Upgrade Plan</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>View plans and pricing</p>
</TooltipContent>
</Tooltip>
</div>
)}
{/* Admin Section */}
<div className="mt-4 pt-4 border-t border-zinc-800">
@@ -130,15 +220,41 @@ export function Sidebar() {
{/* User section at bottom */}
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-white text-sm font-medium">
U
{user ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-white text-sm font-medium">
{user.name.charAt(0).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">{user.name}</span>
<Badge className={cn("text-xs mt-0.5", planColors[user.plan] || "bg-zinc-600")}>
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
</Badge>
</div>
</div>
<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="flex flex-col">
<span className="text-sm font-medium text-white">User</span>
<span className="text-xs text-zinc-500">Translator</span>
) : (
<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>
)}
</div>
</aside>
</TooltipProvider>