Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user