Files
podcastdistributiona/components/app/billing-client.tsx
T
2026-06-07 03:58:32 -04:00

214 lines
8.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2, CreditCard, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { cn, formatPrice } from "@/lib/utils";
import { PLAN_ORDER, PLANS, type PlanKey } from "@/lib/billing/plans";
import type { BillingInterval } from "@/lib/billing/catalog";
import {
startStripeCheckoutAction,
startPaypalCheckoutAction,
openStripePortalAction,
cancelSubscriptionAction,
} from "@/app/(app)/billing/actions";
interface SubInfo {
provider: string;
status: string;
cancelAtPeriodEnd: boolean | null;
periodEnd: string | null;
}
export function BillingClient({
currentPlan,
subscription,
stripeConfigured,
paypalConfigured,
}: {
currentPlan: PlanKey;
subscription: SubInfo | null;
stripeConfigured: boolean;
paypalConfigured: boolean;
}) {
const router = useRouter();
const [interval, setInterval] = useState<BillingInterval>("month");
const [busy, setBusy] = useState<string | null>(null);
async function go(action: () => Promise<{ ok: boolean; url?: string; error?: string }>, tag: string) {
setBusy(tag);
const res = await action();
if (res.ok && res.url) {
window.location.href = res.url;
return;
}
setBusy(null);
if (res.ok) {
toast.success("Done");
router.refresh();
} else {
toast.error(res.error ?? "Something went wrong");
}
}
return (
<div className="space-y-8">
{subscription && currentPlan !== "free" && (
<Card>
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-semibold capitalize">{PLANS[currentPlan].name} plan</span>
<Badge variant={subscription.status === "active" ? "success" : "warning"}>
{subscription.cancelAtPeriodEnd ? "Cancels at period end" : subscription.status}
</Badge>
<Badge variant="outline" className="capitalize">{subscription.provider}</Badge>
</div>
{subscription.periodEnd && (
<p className="mt-1 text-sm text-muted-foreground">
{subscription.cancelAtPeriodEnd ? "Access until" : "Renews"}{" "}
{new Date(subscription.periodEnd).toLocaleDateString()}
</p>
)}
</div>
<div className="flex gap-2">
{subscription.provider === "stripe" && (
<Button variant="outline" onClick={() => go(openStripePortalAction, "portal")} disabled={busy === "portal"}>
{busy === "portal" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
Manage
</Button>
)}
{!subscription.cancelAtPeriodEnd && (
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm("Cancel your subscription at the end of the period?")) {
go(cancelSubscriptionAction, "cancel");
}
}}
disabled={busy === "cancel"}
>
{busy === "cancel" && <Loader2 className="h-4 w-4 animate-spin" />}
Cancel
</Button>
)}
</div>
</CardContent>
</Card>
)}
<div className="flex items-center justify-center gap-2">
<IntervalToggle interval={interval} onChange={setInterval} />
</div>
<div className="grid gap-4 lg:grid-cols-4">
{PLAN_ORDER.map((key) => {
const plan = PLANS[key];
const isCurrent = key === currentPlan;
const price = interval === "year" ? plan.priceYearly : plan.priceMonthly;
return (
<Card key={key} className={cn("relative", plan.highlight && "ring-2 ring-brand")}>
{plan.highlight && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-brand text-brand-foreground shadow-sm">
Most popular
</Badge>
)}
<CardContent className="space-y-4 p-5">
<div>
<h3 className="font-semibold">{plan.name}</h3>
<p className="mt-1 text-xs text-muted-foreground">{plan.tagline}</p>
</div>
<div className="flex items-baseline gap-1">
<span className="font-display text-3xl font-extrabold tracking-tight">{formatPrice(price)}</span>
<span className="text-xs text-muted-foreground">/{interval === "year" ? "yr" : "mo"}</span>
</div>
{isCurrent ? (
<Button variant="secondary" className="w-full" disabled>
<Check className="h-4 w-4" /> Current plan
</Button>
) : key === "free" ? (
<Button variant="outline" className="w-full" disabled>
Free
</Button>
) : (
<div className="space-y-2">
{stripeConfigured && (
<Button
className="w-full"
onClick={() => go(() => startStripeCheckoutAction(key, interval), `stripe-${key}`)}
disabled={busy === `stripe-${key}`}
>
{busy === `stripe-${key}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CreditCard className="h-4 w-4" />
)}
Pay with card
</Button>
)}
{paypalConfigured && (
<Button
variant="outline"
className="w-full"
onClick={() => go(() => startPaypalCheckoutAction(key), `paypal-${key}`)}
disabled={busy === `paypal-${key}`}
>
{busy === `paypal-${key}` ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
PayPal
</Button>
)}
{!stripeConfigured && !paypalConfigured && (
<p className="text-center text-xs text-muted-foreground">Billing not configured</p>
)}
</div>
)}
<ul className="space-y-1.5 pt-2 text-xs">
{plan.bullets.slice(0, 5).map((b) => (
<li key={b} className="flex gap-1.5">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand" />
<span className="text-muted-foreground">{b}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
function IntervalToggle({
interval,
onChange,
}: {
interval: BillingInterval;
onChange: (i: BillingInterval) => void;
}) {
return (
<div className="inline-flex rounded-full border border-border bg-secondary p-1">
{(["month", "year"] as const).map((i) => (
<button
key={i}
onClick={() => onChange(i)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
interval === i ? "bg-background text-foreground shadow-sm" : "text-muted-foreground"
)}
>
{i === "month" ? "Monthly" : "Yearly"}
{i === "year" && <span className="ml-1 text-xs opacity-80">(2 mo free)</span>}
</button>
))}
</div>
);
}