SurfSense/surfsense_web/components/pricing.tsx
Vonic 71edc183c4 feat(story-5.1): add subscription pricing UI with Stripe checkout integration
Replace PAYG pricing tiers with subscription model (Free/Pro/Enterprise),
enable Monthly/Yearly billing toggle, wire Pro CTA to Stripe checkout with
authenticatedFetch, toast error feedback, double-click guard, checkout_url
validation, and offline graceful degradation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:28:14 +07:00

265 lines
8.1 KiB
TypeScript

"use client";
import NumberFlow from "@number-flow/react";
import confetti from "canvas-confetti";
import { Check, Star } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useRef, useState } from "react";
import { buttonVariants } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
interface PricingPlan {
name: string;
price: string;
yearlyPrice: string;
period: string;
billingText?: string;
features: string[];
description: string;
buttonText: string;
href: string;
isPopular: boolean;
onAction?: () => void;
disabled?: boolean;
}
interface PricingProps {
plans: PricingPlan[];
title?: string;
description?: string;
isYearly?: boolean;
onToggleBilling?: (yearly: boolean) => void;
}
export function Pricing({
plans,
title = "Simple, Transparent Pricing",
description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
isYearly: isYearlyProp,
onToggleBilling,
}: PricingProps) {
const [isYearlyLocal, setIsYearlyLocal] = useState(false);
const isYearly = isYearlyProp !== undefined ? isYearlyProp : isYearlyLocal;
const isMonthly = !isYearly;
const isDesktop = useMediaQuery("(min-width: 768px)");
const switchRef = useRef<HTMLButtonElement>(null);
const handleToggle = (checked: boolean) => {
const yearly = checked;
if (onToggleBilling) {
onToggleBilling(yearly);
} else {
setIsYearlyLocal(yearly);
}
if (checked && switchRef.current) {
const rect = switchRef.current.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
confetti({
particleCount: 50,
spread: 60,
origin: {
x: x / window.innerWidth,
y: y / window.innerHeight,
},
colors: [
"hsl(var(--primary))",
"hsl(var(--accent))",
"hsl(var(--secondary))",
"hsl(var(--muted))",
],
ticks: 200,
gravity: 1.2,
decay: 0.94,
startVelocity: 30,
shapes: ["circle"],
});
}
};
return (
<div className="container mx-auto py-20">
<div className="text-center space-y-4 mb-12">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h1>
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
</div>
<div className="flex justify-center mb-10">
<label
htmlFor="billing-toggle"
className="relative inline-flex items-center cursor-pointer gap-3"
>
<span className={`font-semibold ${isMonthly ? "text-foreground" : "text-muted-foreground"}`}>
Monthly
</span>
<Label>
<Switch
ref={switchRef as any}
checked={isYearly}
onCheckedChange={handleToggle}
className="relative"
/>
</Label>
<span className={`font-semibold ${isYearly ? "text-foreground" : "text-muted-foreground"}`}>
Annual <span className="text-primary">(Save 25%)</span>
</span>
</label>
</div>
<div
className={cn(
"grid grid-cols-1 gap-4",
plans.length === 2 ? "md:grid-cols-2 max-w-5xl mx-auto" : "md:grid-cols-3"
)}
>
{plans.map((plan, index) => (
<motion.div
key={plan.name}
initial={{ y: 50, opacity: 1 }}
whileInView={
isDesktop
? plans.length === 2
? {
y: plan.isPopular ? -20 : 0,
opacity: 1,
scale: plan.isPopular ? 1.0 : 0.96,
}
: {
y: plan.isPopular ? -20 : 0,
opacity: 1,
x: index === 2 ? -30 : index === 0 ? 30 : 0,
scale: index === 0 || index === 2 ? 0.94 : 1.0,
}
: {}
}
viewport={{ once: true }}
transition={{
duration: 1.6,
type: "spring",
stiffness: 100,
damping: 30,
delay: 0.4,
opacity: { duration: 0.5 },
}}
className={cn(
`rounded-2xl border-[1px] p-6 bg-background text-center lg:flex lg:flex-col lg:justify-center relative`,
plan.isPopular ? "border-primary border-2" : "border-border",
"flex flex-col",
!plan.isPopular && "mt-5",
plans.length === 3 && (index === 0 || index === 2)
? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-[10deg]"
: plans.length === 2 && !plan.isPopular
? "z-0"
: "z-10",
plans.length === 3 && index === 0 && "origin-right",
plans.length === 3 && index === 2 && "origin-left"
)}
>
{plan.isPopular && (
<div className="absolute top-0 right-0 bg-primary py-0.5 px-2 rounded-bl-xl rounded-tr-xl flex items-center">
<Star className="text-primary-foreground h-4 w-4 fill-current" />
<span className="text-primary-foreground ml-1 font-sans font-semibold">
Popular
</span>
</div>
)}
<div className="flex-1 flex flex-col">
<p className="text-base font-semibold text-muted-foreground">{plan.name}</p>
<div className="mt-6 flex items-center justify-center gap-x-2">
<span className="text-5xl font-bold tracking-tight text-foreground">
{isNaN(Number(plan.price)) ? (
<span>{isMonthly ? plan.price : plan.yearlyPrice}</span>
) : (
<NumberFlow
value={isMonthly ? Number(plan.price) : Number(plan.yearlyPrice)}
format={{
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}}
transformTiming={{
duration: 500,
easing: "ease-out",
}}
willChange
className="font-variant-numeric: tabular-nums"
/>
)}
</span>
{plan.period && plan.period !== "Next 3 months" && (
<span className="text-sm font-semibold leading-6 tracking-wide text-muted-foreground">
/ {plan.period}
</span>
)}
</div>
<p className="text-xs leading-5 text-muted-foreground">
{plan.billingText ??
(isNaN(Number(plan.price))
? ""
: isMonthly
? "billed monthly"
: "billed annually")}
</p>
<ul className="mt-5 gap-2 flex flex-col">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-4 w-4 text-primary mt-1 flex-shrink-0" />
<span className="text-left">{feature}</span>
</li>
))}
</ul>
<hr className="w-full my-4" />
{plan.onAction ? (
<button
type="button"
onClick={plan.onAction}
disabled={plan.disabled}
className={cn(
buttonVariants({
variant: "outline",
}),
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
plan.isPopular
? "bg-primary text-primary-foreground"
: "bg-background text-foreground",
plan.disabled && "opacity-50 cursor-not-allowed hover:ring-0 hover:bg-inherit hover:text-inherit"
)}
>
{plan.buttonText}
</button>
) : (
<Link
href={plan.href}
className={cn(
buttonVariants({
variant: "outline",
}),
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
plan.isPopular
? "bg-primary text-primary-foreground"
: "bg-background text-foreground"
)}
>
{plan.buttonText}
</Link>
)}
<p className="mt-6 text-xs leading-5 text-muted-foreground">{plan.description}</p>
</div>
</motion.div>
))}
</div>
</div>
);
}