writekit/studio/src/pages/BillingPage.tsx
2026-01-09 00:16:46 +02:00

251 lines
11 KiB
TypeScript

import { useState } from 'react'
import { useStore } from '@nanostores/react'
import { PageHeader, BillingPageSkeleton } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button } from '../components/ui'
import { $billing } from '../stores/billing'
import type { Tier, TierConfig } from '../types'
type BillingCycle = 'monthly' | 'annual'
function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(0)}`
}
function getFeatureList(config: TierConfig, tier: Tier): { name: string; included: boolean }[] {
if (tier === 'free') {
return [
{ name: 'Unlimited posts', included: true },
{ name: 'Comments & reactions', included: true },
{ name: 'writekit.dev subdomain', included: true },
{ name: `${config.analytics_retention}-day analytics`, included: true },
{ name: `API access (${config.api_rate_limit} req/hr)`, included: true },
{ name: `${config.max_webhooks} webhooks`, included: true },
{ name: `${config.max_plugins} plugins`, included: true },
{ name: 'Custom domain', included: false },
{ name: 'Remove "Powered by" badge', included: false },
]
}
return [
{ name: 'Unlimited posts', included: true },
{ name: 'Comments & reactions', included: true },
{ name: 'writekit.dev subdomain', included: true },
{ name: 'Custom domain', included: true },
{ name: 'No "Powered by" badge', included: true },
{ name: `${config.analytics_retention}-day analytics`, included: true },
{ name: `API access (${config.api_rate_limit} req/hr)`, included: true },
{ name: `${config.max_webhooks} webhooks`, included: true },
{ name: `${config.max_plugins} plugins`, included: true },
{ name: 'Priority support', included: true },
]
}
export default function BillingPage() {
const { data: billing } = useStore($billing)
const [billingCycle, setBillingCycle] = useState<BillingCycle>('annual')
if (!billing) return <BillingPageSkeleton />
const currentTier = billing.current_tier
const currentConfig = billing.tiers[currentTier]
const proConfig = billing.tiers.pro
const proFeatures = getFeatureList(proConfig, 'pro')
const annualSavings = (proConfig.monthly_price * 12 - proConfig.annual_price) / 100
return (
<div>
<PageHeader />
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Current Plan */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Current Plan</div>
</div>
<div className="px-6 lg:px-10 pb-6">
<div className="p-4 border border-accent bg-accent/5">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-medium">{currentConfig.name}</span>
<span className="px-2 py-0.5 bg-accent/20 text-accent text-xs font-medium">Active</span>
</div>
<div className="text-sm text-muted mt-1">{currentConfig.description}</div>
</div>
{currentTier === 'free' && (
<Button variant="primary" href="#upgrade">Upgrade to Pro</Button>
)}
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Upgrade Section */}
{currentTier === 'free' && (
<>
<div id="upgrade" className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Upgrade to Pro</div>
<div className="text-xs text-muted mt-0.5">Get custom domain, extended analytics, and more</div>
</div>
<div className="px-6 lg:px-10 pb-6">
{/* Billing Toggle */}
<div className="flex items-center justify-center gap-3 mb-6">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
billingCycle === 'monthly' ? 'text-text' : 'text-muted hover:text-text'
}`}
>
Monthly
</button>
<button
onClick={() => setBillingCycle('annual')}
className={`px-3 py-1.5 text-sm font-medium transition-colors flex items-center gap-2 ${
billingCycle === 'annual' ? 'text-text' : 'text-muted hover:text-text'
}`}
>
Annual
<span className="px-1.5 py-0.5 bg-success/20 text-success text-xs">
Save ${annualSavings}
</span>
</button>
</div>
{/* Pro Plan Card */}
<div className="max-w-md mx-auto p-6 border border-border">
<div className="text-center mb-6">
<div className="text-sm font-medium text-muted mb-2">Pro</div>
<div className="text-4xl font-semibold tracking-tight">
{formatPrice(billingCycle === 'monthly' ? proConfig.monthly_price : proConfig.annual_price)}
<span className="text-base font-normal text-muted">
/{billingCycle === 'monthly' ? 'mo' : 'yr'}
</span>
</div>
{billingCycle === 'annual' && (
<div className="text-xs text-muted mt-1">
{formatPrice(Math.round(proConfig.annual_price / 12))}/mo billed annually
</div>
)}
</div>
<ul className="space-y-3 mb-6">
{proFeatures.map((feature) => (
<li key={feature.name} className="flex items-center gap-2 text-sm">
<Icons.Check className="text-success flex-shrink-0" />
<span>{feature.name}</span>
</li>
))}
</ul>
<Button variant="primary" className="w-full">
Upgrade to Pro
</Button>
<p className="text-xs text-muted text-center mt-3">
Secure payment via Lemon Squeezy. Cancel anytime.
</p>
</div>
</div>
<div className="border-t border-border" />
</>
)}
{/* Feature Comparison */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Feature Comparison</div>
</div>
<div className="px-6 lg:px-10 pb-6">
<div className="border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/5">
<th className="text-left px-4 py-3 font-medium">Feature</th>
<th className="text-center px-4 py-3 font-medium w-24">Free</th>
<th className="text-center px-4 py-3 font-medium w-24">Pro</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border">
<td className="px-4 py-3">Custom domain</td>
<td className="text-center px-4 py-3"><Icons.Close className="text-muted/50 inline" /></td>
<td className="text-center px-4 py-3"><Icons.Check className="text-success inline" /></td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">"Powered by WriteKit" badge</td>
<td className="text-center px-4 py-3 text-muted">Required</td>
<td className="text-center px-4 py-3 text-muted">Hidden</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Analytics retention</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.analytics_retention} days</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.analytics_retention} days</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">API rate limit</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.api_rate_limit}/hr</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.api_rate_limit}/hr</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Webhooks</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.max_webhooks}</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.max_webhooks}</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Plugins</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.max_plugins}</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.max_plugins}</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Posts</td>
<td className="text-center px-4 py-3 text-muted">Unlimited</td>
<td className="text-center px-4 py-3 text-muted">Unlimited</td>
</tr>
<tr>
<td className="px-4 py-3">Support</td>
<td className="text-center px-4 py-3 text-muted">Community</td>
<td className="text-center px-4 py-3 text-muted">Priority</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="border-t border-border" />
{/* FAQ */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Questions</div>
</div>
<div className="px-6 lg:px-10 pb-6 space-y-4">
<div>
<div className="text-sm font-medium mb-1">Can I cancel anytime?</div>
<div className="text-sm text-muted">
Yes. Cancel anytime and keep access until the end of your billing period.
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Can I switch from monthly to annual?</div>
<div className="text-sm text-muted">
Yes. Switch anytime and we'll prorate your payment.
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">What happens to my content if I downgrade?</div>
<div className="text-sm text-muted">
Your content stays. Custom domain will stop working, badge will appear, and analytics older than 7 days won't be accessible.
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Can I export my data?</div>
<div className="text-sm text-muted">
Yes. Export all your posts, settings, and assets anytime from the Data page. Your data is always yours.
</div>
</div>
</div>
</div>
</div>
)
}