mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat: add billing management page with tier comparison + checkout
- Create /dashboard/organization/billing page with plan comparison - Wire up disabled "Manage Subscription" button to new billing page - Fix analytics nav billing link to point to organization/billing - Shows current plan, usage stats, and upgrade via Commerce checkout - All 4 tiers displayed: Free, PAYG, Growth, Enterprise
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { billing } from "@/lib/api"
|
||||
import {
|
||||
CreditCard,
|
||||
Check,
|
||||
Zap,
|
||||
Loader2,
|
||||
ArrowUpRight,
|
||||
AlertCircle,
|
||||
} from "lucide-react"
|
||||
|
||||
interface Tier {
|
||||
name: string
|
||||
slug: string
|
||||
monthly_cu: number
|
||||
rate_limit_per_second: number
|
||||
max_apps: number
|
||||
max_webhooks: number
|
||||
price_per_million_cu: number
|
||||
features: string[]
|
||||
support_level: string
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
tier: string
|
||||
status: string
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
}
|
||||
|
||||
interface UsageSummary {
|
||||
total_cu: number
|
||||
cost_cents: number
|
||||
period: string
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tiers, setTiers] = useState<Tier[]>([])
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null)
|
||||
const [upgrading, setUpgrading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBillingData() {
|
||||
try {
|
||||
const [tiersData, subData, usageData] = await Promise.allSettled([
|
||||
billing.getTiers(),
|
||||
billing.getSubscription(),
|
||||
billing.getUsageSummary(),
|
||||
])
|
||||
|
||||
if (tiersData.status === "fulfilled") {
|
||||
setTiers(tiersData.value as Tier[])
|
||||
}
|
||||
if (subData.status === "fulfilled") {
|
||||
setSubscription(subData.value as Subscription)
|
||||
}
|
||||
if (usageData.status === "fulfilled") {
|
||||
setUsage(usageData.value as UsageSummary)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch billing data:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchBillingData()
|
||||
}, [])
|
||||
|
||||
async function handleUpgrade(tierSlug: string) {
|
||||
setUpgrading(tierSlug)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await billing.createCheckout({
|
||||
tier: tierSlug,
|
||||
success_url: `${window.location.origin}/dashboard/organization/billing?upgraded=true`,
|
||||
cancel_url: `${window.location.origin}/dashboard/organization/billing`,
|
||||
}) as { checkout_url?: string }
|
||||
|
||||
if (result.checkout_url) {
|
||||
window.location.href = result.checkout_url
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start checkout")
|
||||
} finally {
|
||||
setUpgrading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const currentTier = subscription?.tier || "free"
|
||||
|
||||
// Fallback tiers if API hasn't returned them yet
|
||||
const displayTiers: Tier[] = tiers.length > 0 ? tiers : [
|
||||
{
|
||||
name: "Free", slug: "free", monthly_cu: 30_000_000,
|
||||
rate_limit_per_second: 25, max_apps: 5, max_webhooks: 5,
|
||||
price_per_million_cu: 0, support_level: "community",
|
||||
features: ["30M compute units/month", "25 req/s", "5 apps", "5 webhooks", "Community support"],
|
||||
},
|
||||
{
|
||||
name: "Pay As You Go", slug: "payg", monthly_cu: 0,
|
||||
rate_limit_per_second: 300, max_apps: 30, max_webhooks: 100,
|
||||
price_per_million_cu: 40, support_level: "email",
|
||||
features: ["Unlimited compute units", "300 req/s", "30 apps", "100 webhooks", "Email support", "Usage analytics"],
|
||||
},
|
||||
{
|
||||
name: "Growth", slug: "growth", monthly_cu: 100_000_000,
|
||||
rate_limit_per_second: 500, max_apps: 50, max_webhooks: 250,
|
||||
price_per_million_cu: 35, support_level: "priority",
|
||||
features: ["100M CU included", "500 req/s", "50 apps", "250 webhooks", "Priority support", "Advanced analytics"],
|
||||
},
|
||||
{
|
||||
name: "Enterprise", slug: "enterprise", monthly_cu: 0,
|
||||
rate_limit_per_second: 1000, max_apps: 0, max_webhooks: 500,
|
||||
price_per_million_cu: 0, support_level: "dedicated",
|
||||
features: ["Custom CU", "Custom rate limits", "Unlimited apps", "500+ webhooks", "Dedicated support", "SLA guarantee"],
|
||||
},
|
||||
]
|
||||
|
||||
function tierPrice(tier: Tier): string {
|
||||
if (tier.slug === "free") return "Free"
|
||||
if (tier.slug === "enterprise") return "Custom"
|
||||
if (tier.slug === "payg") return `$${(tier.price_per_million_cu / 100).toFixed(2)}/M CU`
|
||||
// Growth: show base price estimate
|
||||
return `$${(tier.price_per_million_cu / 100).toFixed(2)}/M CU`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Billing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your subscription and usage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="p-4 flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current Plan & Usage */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
Current Plan
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Badge variant="default" className="text-base px-3 py-1">
|
||||
{currentTier.charAt(0).toUpperCase() + currentTier.slice(1)}
|
||||
</Badge>
|
||||
{subscription?.status && (
|
||||
<Badge variant={subscription.status === "active" ? "default" : "secondary"}>
|
||||
{subscription.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{subscription?.current_period_end && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current period ends {new Date(subscription.current_period_end).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Usage This Period
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usage ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{((usage.total_cu || 0) / 1_000_000).toFixed(1)}M CU
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{usage.cost_cents
|
||||
? `$${(usage.cost_cents / 100).toFixed(2)} estimated cost`
|
||||
: "No charges this period"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-muted-foreground">-</div>
|
||||
<p className="text-sm text-muted-foreground">No usage data available</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Pricing Tiers */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Plans</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{displayTiers.map((tier) => {
|
||||
const isCurrent = tier.slug === currentTier
|
||||
const isUpgrade = !isCurrent && tier.slug !== "free" && tier.slug !== "enterprise"
|
||||
return (
|
||||
<Card key={tier.slug} className={isCurrent ? "border-primary" : ""}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
{tier.name}
|
||||
{isCurrent && <Badge>Current</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg font-semibold text-foreground">
|
||||
{tierPrice(tier)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{tier.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2">
|
||||
<Check className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{isCurrent ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Current Plan
|
||||
</Button>
|
||||
) : tier.slug === "enterprise" ? (
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href="mailto:[email protected]">
|
||||
Contact Sales
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : isUpgrade ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleUpgrade(tier.slug)}
|
||||
disabled={upgrading !== null}
|
||||
>
|
||||
{upgrading === tier.slug ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : null}
|
||||
Upgrade
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" className="w-full" disabled>
|
||||
-
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Billing via Hanzo Commerce</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Payments are processed securely via Square through Hanzo Commerce.
|
||||
Compute units are tracked in real-time and billed at the end of each period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -209,8 +209,10 @@ export default function OrganizationPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to access more features and higher rate limits.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Manage Subscription
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/organization/billing">
|
||||
Manage Subscription
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function UnifiedNavigation() {
|
||||
children: [
|
||||
{ name: "Usage", href: "/dashboard/analytics/usage", icon: Activity },
|
||||
{ name: "Performance", href: "/dashboard/analytics/performance", icon: Zap },
|
||||
{ name: "Billing", href: "/dashboard/analytics/billing", icon: CreditCard },
|
||||
{ name: "Billing", href: "/dashboard/organization/billing", icon: CreditCard },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user