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:
169
frontend/src/app/auth/login/page.tsx
Normal file
169
frontend/src/app/auth/login/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
206
frontend/src/app/auth/register/page.tsx
Normal file
206
frontend/src/app/auth/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
frontend/src/app/dashboard/page.tsx
Normal file
363
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
364
frontend/src/app/ollama-setup/page.tsx
Normal file
364
frontend/src/app/ollama-setup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
421
frontend/src/app/pricing/page.tsx
Normal file
421
frontend/src/app/pricing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
frontend/src/components/landing-sections.tsx
Normal file
309
frontend/src/components/landing-sections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user