214 lines
8.0 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|