mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 20:27:44 +00:00
feat: stripe plugin to handle subscriptions and customers (#1588)
* init * wip * wip * wip * wip * wip * wip * wip * wip * wip * feat(stripe): enable subscription support and update pricing plans * feat(stripe): add Vitest configuration and initial tests for Stripe integration * feat(stripe): implement setCookieToHeader function and update tests for customer creation and subscription handling * feat(stripe): add seats support for subscriptions and update related endpoints * feat(stripe): update schema to include unique referenceId, stripeSubscriptionId, and periodEnd fields * wip docs * docs * docs: imporves * fix(stripe): update webhook handlers to use correct subscription identification * refactor(stripe): simplify customer management by storing Stripe customer ID directly on user * chore(stripe): update package configuration and build setup - Migrated from tsup to unbuild for build configuration - Updated package.json with improved export and dependency management - Added build configuration for better module support - Removed tsup configuration file * chore(stripe): update pnpm lockfile dependencies - Moved `better-auth` from devDependencies to dependencies - Added `zod` as a direct dependency - Reorganized package dependencies in the lockfile * feat(stripe): enhance subscription management and error handling - Added toast error handling for subscription upgrades in the dashboard - Updated Stripe price IDs for different plans - Improved Stripe plugin documentation with beta warning and team subscription details - Implemented intermediate redirect for checkout success to handle race conditions - Added support for fetching and updating subscription status after checkout - Fixed Next.js cookie handling and build configuration * chore: update snapshot
This commit is contained in:
235
demo/nextjs/components/blocks/pricing.tsx
Normal file
235
demo/nextjs/components/blocks/pricing.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { Star } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import confetti from "canvas-confetti";
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import { CheckIcon } from "@radix-ui/react-icons";
|
||||
import { client } from "@/lib/auth-client";
|
||||
|
||||
function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addListener(listener);
|
||||
|
||||
return () => media.removeListener(listener);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
price: string;
|
||||
yearlyPrice: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
description: string;
|
||||
buttonText: string;
|
||||
href: string;
|
||||
isPopular: boolean;
|
||||
}
|
||||
|
||||
interface PricingProps {
|
||||
plans: PricingPlan[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function Pricing({
|
||||
plans,
|
||||
title = "Simple, Transparent Pricing",
|
||||
description = "Choose the plan that works for you",
|
||||
}: PricingProps) {
|
||||
const [isMonthly, setIsMonthly] = useState(true);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const switchRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
setIsMonthly(!checked);
|
||||
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 py-4">
|
||||
<div className="text-center space-y-4 mb-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground whitespace-pre-line">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-10">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<Label>
|
||||
<Switch
|
||||
ref={switchRef as any}
|
||||
checked={!isMonthly}
|
||||
onCheckedChange={handleToggle}
|
||||
className="relative"
|
||||
/>
|
||||
</Label>
|
||||
</label>
|
||||
<span className="ml-2 font-semibold">
|
||||
Annual billing <span className="text-primary">(Save 20%)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 sm:2 gap-4">
|
||||
{plans.map((plan, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ y: 50, opacity: 1 }}
|
||||
whileInView={
|
||||
isDesktop
|
||||
? {
|
||||
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-sm border-[1px] p-6 bg-background text-center lg:flex lg:flex-col lg:justify-center relative`,
|
||||
plan.isPopular ? "border-border border-2" : "border-border",
|
||||
"flex flex-col",
|
||||
!plan.isPopular && "mt-5",
|
||||
index === 0 || index === 2
|
||||
? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-[10deg]"
|
||||
: "z-10",
|
||||
index === 0 && "origin-right",
|
||||
index === 2 && "origin-left",
|
||||
)}
|
||||
>
|
||||
{plan.isPopular && (
|
||||
<div className="absolute top-0 right-0 bg-primary py-0.5 px-2 rounded-bl-sm rounded-tr-sm 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 mt-2">
|
||||
{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">
|
||||
<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 !== "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">
|
||||
{isMonthly ? "billed monthly" : "billed annually"}
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 gap-2 flex flex-col">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<CheckIcon 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" />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await client.subscription.upgrade({
|
||||
plan: plan.name.toLowerCase(),
|
||||
successUrl: "/dashboard",
|
||||
});
|
||||
}}
|
||||
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}
|
||||
</Button>
|
||||
<p className="mt-6 text-xs leading-5 text-muted-foreground">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
demo/nextjs/components/tier-labels.tsx
Normal file
38
demo/nextjs/components/tier-labels.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tierVariants = cva(
|
||||
"inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-inset transition-all duration-300 ease-in-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
free: "bg-gray-500 text-white ring-gray-400 hover:bg-gray-600",
|
||||
starter: "bg-lime-700/40 text-white ring-lime-200/40 hover:bg-lime-600",
|
||||
professional: "bg-purple-800/80 ring-purple-400 hover:bg-purple-700",
|
||||
enterprise: "bg-amber-500 text-black ring-amber-400 hover:bg-amber-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "free",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface SubscriptionTierLabelProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof tierVariants> {
|
||||
tier?: "free" | "starter" | "professional" | "enterprise";
|
||||
}
|
||||
|
||||
export const SubscriptionTierLabel: React.FC<SubscriptionTierLabelProps> = ({
|
||||
tier = "free",
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span className={cn(tierVariants({ variant: tier }), className)} {...props}>
|
||||
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user