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