stripe-donation-widget
Version:
A complete, reusable donation system for React/Next.js applications with Stripe integration, real-time MRR tracking, and beautiful UI components.
708 lines (691 loc) • 38 kB
JavaScript
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
import { Loader2, Heart } from 'lucide-react';
import { z } from 'zod';
import * as React from 'react';
import { useState, useCallback, useEffect } from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva } from 'class-variance-authority';
import * as LabelPrimitive from '@radix-ui/react-label';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import Stripe from 'stripe';
const donationConfigSchema = z.object({
stripePublishableKey: z.string().min(1, 'Stripe publishable key is required'),
projectName: z.string().min(1, 'Project name is required'),
projectSlug: z.string()
.min(1, 'Project slug is required')
.regex(/^[a-z0-9-]+$/, 'Project slug must contain only lowercase letters, numbers, and hyphens'),
monthly: z.object({
suggested: z.number().positive('Monthly suggested amount must be positive'),
min: z.number().min(0, 'Monthly minimum amount must be non-negative').optional(),
max: z.number().positive().optional(),
}).refine(data => !data.max || !data.min || data.max >= data.min, {
message: 'Monthly maximum must be greater than or equal to minimum',
}),
annual: z.object({
suggested: z.number().positive('Annual suggested amount must be positive'),
min: z.number().min(0, 'Annual minimum amount must be non-negative').optional(),
max: z.number().positive().optional(),
}).refine(data => !data.max || !data.min || data.max >= data.min, {
message: 'Annual maximum must be greater than or equal to minimum',
}),
goal: z.object({
target: z.number().positive('Goal target must be positive'),
current: z.number().min(0, 'Goal current amount must be non-negative').optional().default(0),
showProgress: z.boolean().optional().default(true),
description: z.string().optional(),
calculateFromSubscriptions: z.boolean().optional().default(false),
}).optional(),
theme: z.enum(['light', 'dark', 'auto']).optional().default('auto'),
customAmounts: z.boolean().optional().default(true),
successUrl: z.string().url().optional(),
cancelUrl: z.string().url().optional(),
currency: z.string().length(3).optional().default('usd'),
});
function validateDonationConfig(config) {
return donationConfigSchema.parse(config);
}
function generateProductIds(projectSlug) {
return {
monthly: `${projectSlug}-monthly-donation`,
annual: `${projectSlug}-annual-donation`,
};
}
function formatCurrency(amount, currency = 'usd') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount);
}
function getDefaultUrls(baseUrl) {
const base = (typeof window !== 'undefined' ? window.location.origin : '');
return {
successUrl: `${base}/donation/success`,
cancelUrl: `${base}/donation/cancel`,
};
}
function getDefaultMinAmount(suggestedAmount) {
// Default minimum is 50% of suggested amount, with a floor of $1
return Math.max(1, Math.round(suggestedAmount * 0.5));
}
function useDonation(config) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Validate config on hook initialization
const validatedConfig = validateDonationConfig(config);
const clearError = useCallback(() => {
setError(null);
}, []);
const createCheckoutSession = useCallback(async (type, customAmount) => {
try {
setIsLoading(true);
setError(null);
// Determine the amount to use
const configAmount = type === 'monthly'
? validatedConfig.monthly.suggested
: validatedConfig.annual.suggested;
const amount = customAmount || configAmount;
// Validate amount against config limits
const limits = type === 'monthly' ? validatedConfig.monthly : validatedConfig.annual;
const minAmount = limits.min ?? getDefaultMinAmount(limits.suggested);
if (amount < minAmount) {
throw new Error(`Amount must be at least ${minAmount}`);
}
if (limits.max && amount > limits.max) {
throw new Error(`Amount must not exceed ${limits.max}`);
}
// Get default URLs if not provided
const { successUrl, cancelUrl } = getDefaultUrls();
const sessionData = {
type,
amount,
projectSlug: validatedConfig.projectSlug,
projectName: validatedConfig.projectName,
successUrl: validatedConfig.successUrl || successUrl,
cancelUrl: validatedConfig.cancelUrl || cancelUrl,
};
// Call the API to create checkout session
const response = await fetch('/api/donation/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sessionData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to create checkout session');
}
const { sessionId, url } = await response.json();
// Redirect to Stripe Checkout
if (url) {
window.location.href = url;
}
else {
throw new Error('No checkout URL received');
}
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred';
setError(errorMessage);
console.error('Donation checkout error:', err);
}
finally {
setIsLoading(false);
}
}, [validatedConfig]);
return {
createCheckoutSession,
isLoading,
error,
clearError,
};
}
function cn(...inputs) {
return twMerge(clsx(inputs));
}
// packages/react/compose-refs/src/compose-refs.tsx
function setRef(ref, value) {
if (typeof ref === "function") {
return ref(value);
} else if (ref !== null && ref !== void 0) {
ref.current = value;
}
}
function composeRefs(...refs) {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup == "function") {
hasCleanup = true;
}
return cleanup;
});
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup == "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
// src/slot.tsx
// @__NO_SIDE_EFFECTS__
function createSlot(ownerName) {
const SlotClone = /* @__PURE__ */ createSlotClone(ownerName);
const Slot2 = React.forwardRef((props, forwardedRef) => {
const { children, ...slotProps } = props;
const childrenArray = React.Children.toArray(children);
const slottable = childrenArray.find(isSlottable);
if (slottable) {
const newElement = slottable.props.children;
const newChildren = childrenArray.map((child) => {
if (child === slottable) {
if (React.Children.count(newElement) > 1) return React.Children.only(null);
return React.isValidElement(newElement) ? newElement.props.children : null;
} else {
return child;
}
});
return /* @__PURE__ */ jsx(SlotClone, { ...slotProps, ref: forwardedRef, children: React.isValidElement(newElement) ? React.cloneElement(newElement, void 0, newChildren) : null });
}
return /* @__PURE__ */ jsx(SlotClone, { ...slotProps, ref: forwardedRef, children });
});
Slot2.displayName = `${ownerName}.Slot`;
return Slot2;
}
var Slot = /* @__PURE__ */ createSlot("Slot");
// @__NO_SIDE_EFFECTS__
function createSlotClone(ownerName) {
const SlotClone = React.forwardRef((props, forwardedRef) => {
const { children, ...slotProps } = props;
if (React.isValidElement(children)) {
const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
}
return React.cloneElement(children, props2);
}
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});
SlotClone.displayName = `${ownerName}.SlotClone`;
return SlotClone;
}
var SLOTTABLE_IDENTIFIER = Symbol("radix.slottable");
function isSlottable(child) {
return React.isValidElement(child) && typeof child.type === "function" && "__radixId" in child.type && child.type.__radixId === SLOTTABLE_IDENTIFIER;
}
function mergeProps(slotProps, childProps) {
const overrideProps = { ...childProps };
for (const propName in childProps) {
const slotPropValue = slotProps[propName];
const childPropValue = childProps[propName];
const isHandler = /^on[A-Z]/.test(propName);
if (isHandler) {
if (slotPropValue && childPropValue) {
overrideProps[propName] = (...args) => {
const result = childPropValue(...args);
slotPropValue(...args);
return result;
};
} else if (slotPropValue) {
overrideProps[propName] = slotPropValue;
}
} else if (propName === "style") {
overrideProps[propName] = { ...slotPropValue, ...childPropValue };
} else if (propName === "className") {
overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(" ");
}
}
return { ...slotProps, ...overrideProps };
}
function getElementRef(element) {
let getter = Object.getOwnPropertyDescriptor(element.props, "ref")?.get;
let mayWarn = getter && "isReactWarning" in getter && getter.isReactWarning;
if (mayWarn) {
return element.ref;
}
getter = Object.getOwnPropertyDescriptor(element, "ref")?.get;
mayWarn = getter && "isReactWarning" in getter && getter.isReactWarning;
if (mayWarn) {
return element.props.ref;
}
return element.props.ref || element.ref;
}
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", {
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (jsx(Comp, { className: cn(buttonVariants({ variant, size, className })), ref: ref, ...props }));
});
Button.displayName = "Button";
function DonationButton({ config, type, amount, className, children, onSuccess, onError, }) {
const { createCheckoutSession, isLoading, error } = useDonation(config);
// Use provided amount or fall back to suggested amount
const effectiveAmount = amount || (type === 'monthly' ? config.monthly.suggested : config.annual.suggested);
const handleClick = async () => {
try {
await createCheckoutSession(type, effectiveAmount);
onSuccess?.('checkout-initiated');
}
catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
onError?.(error);
}
};
const formattedAmount = formatCurrency(effectiveAmount, config.currency);
const frequency = type === 'monthly' ? '/month' : '/year';
return (jsxs("div", { className: "space-y-2", children: [jsx(Button, { onClick: handleClick, disabled: isLoading, className: cn("relative overflow-hidden transition-all duration-200", "hover:scale-105 active:scale-95", className), children: isLoading ? (jsxs(Fragment, { children: [jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), "Processing..."] })) : (jsxs(Fragment, { children: [jsx(Heart, { className: "mr-2 h-4 w-4" }), children || (jsxs(Fragment, { children: ["Support with ", formattedAmount, jsx("span", { className: "text-sm opacity-80 ml-1", children: frequency })] }))] })) }), error && (jsx("p", { className: "text-sm text-red-600 mt-2", children: error }))] }));
}
function useDonationStats(config) {
const [stats, setStats] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const fetchStats = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(`/api/donation/stats?projectSlug=${config.projectSlug}`);
if (!response.ok) {
throw new Error('Failed to fetch donation stats');
}
const data = await response.json();
setStats(data);
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch stats';
setError(errorMessage);
console.error('Donation stats error:', err);
}
finally {
setIsLoading(false);
}
};
useEffect(() => {
// Only fetch if we want to calculate from subscriptions
if (config.goal?.calculateFromSubscriptions) {
fetchStats();
}
}, [config.projectSlug, config.goal?.calculateFromSubscriptions]);
return {
stats,
isLoading,
error,
refetch: fetchStats,
};
}
const Card = React.forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: cn("rounded-xl border bg-card text-card-foreground shadow", className), ...props })));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: cn("flex flex-col space-y-1.5 p-6", className), ...props })));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: cn("font-semibold leading-none tracking-tight", className), ...props })));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: cn("p-6 pt-0", className), ...props })));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (jsx("div", { ref: ref, className: cn("flex items-center p-6 pt-0", className), ...props })));
CardFooter.displayName = "CardFooter";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef(({ className, ...props }, ref) => (jsx(LabelPrimitive.Root, { ref: ref, className: cn(labelVariants(), className), ...props })));
Label.displayName = LabelPrimitive.Root.displayName;
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (jsx(ProgressPrimitive.Root, { ref: ref, className: cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className), ...props, children: jsx(ProgressPrimitive.Indicator, { className: "h-full w-full flex-1 bg-primary transition-all", style: { transform: `translateX(-${100 - (value || 0)}%)` } }) })));
Progress.displayName = ProgressPrimitive.Root.displayName;
const Slider = React.forwardRef(({ className, ...props }, ref) => (jsxs(SliderPrimitive.Root, { ref: ref, className: cn("relative flex w-full touch-none select-none items-center", className), ...props, children: [jsx(SliderPrimitive.Track, { className: "relative h-2 w-full grow overflow-hidden rounded-full bg-secondary", children: jsx(SliderPrimitive.Range, { className: "absolute h-full bg-primary" }) }), jsx(SliderPrimitive.Thumb, { className: "block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" })] })));
Slider.displayName = SliderPrimitive.Root.displayName;
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef(({ className, ...props }, ref) => (jsx(TabsPrimitive.List, { ref: ref, className: cn("inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", className), ...props })));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (jsx(TabsPrimitive.Trigger, { ref: ref, className: cn("inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", className), ...props })));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (jsx(TabsPrimitive.Content, { ref: ref, className: cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className), ...props })));
TabsContent.displayName = TabsPrimitive.Content.displayName;
function DonationWidget({ config, showProgress, goal, current, className = "", onSuccess, onError, }) {
const [activeTab, setActiveTab] = useState("monthly");
// Initialize slider values with suggested amounts
const [monthlyAmount, setMonthlyAmount] = useState(config.monthly.suggested);
const [annualAmount, setAnnualAmount] = useState(config.annual.suggested);
const { createCheckoutSession, isLoading, error, clearError } = useDonation(config);
const { stats, isLoading: statsLoading } = useDonationStats(config);
// Determine goal values - use real data if available and enabled
const shouldShowProgress = showProgress ?? config.goal?.showProgress ?? false;
const goalTarget = goal ?? config.goal?.target ?? 0;
let goalCurrent = current ?? config.goal?.current ?? 0;
if (config.goal?.calculateFromSubscriptions && stats) {
goalCurrent = stats.totalMRR;
}
const progressPercentage = goalTarget > 0 ? Math.min((goalCurrent / goalTarget) * 100, 100) : 0;
const handleDonate = async (type) => {
try {
clearError();
const amount = type === "monthly" ? monthlyAmount : annualAmount;
await createCheckoutSession(type, amount);
onSuccess?.('checkout-initiated');
}
catch (err) {
const error = err instanceof Error ? err : new Error("Donation failed");
onError?.(error);
}
};
const getMinAmount = (type) => {
const suggested = type === "monthly" ? config.monthly.suggested : config.annual.suggested;
const configMin = type === "monthly" ? config.monthly.min : config.annual.min;
return configMin ?? Math.max(1, Math.round(suggested * 0.5));
};
const getMaxAmount = (type) => {
const suggested = type === "monthly" ? config.monthly.suggested : config.annual.suggested;
const configMax = type === "monthly" ? config.monthly.max : config.annual.max;
return configMax ?? suggested * 10; // Default max is 10x suggested amount
};
// Generate preset amounts for quick selection
const getPresetAmounts = (type) => {
const suggested = type === "monthly" ? config.monthly.suggested : config.annual.suggested;
const min = getMinAmount(type);
const max = getMaxAmount(type);
// Create 3-4 preset amounts around the suggested amount
const presets = [
Math.max(min, Math.round(suggested * 0.5)),
suggested,
Math.round(suggested * 2),
Math.min(max, Math.round(suggested * 5))
].filter((amount, index, arr) => arr.indexOf(amount) === index); // Remove duplicates
return presets;
};
return (jsxs(Card, { className: className, children: [jsxs(CardHeader, { children: [jsxs(CardTitle, { className: "flex items-center gap-2", children: [jsx(Heart, { className: "h-5 w-5 text-red-500" }), "Support ", config.projectName] }), config.goal?.description && (jsx("p", { className: "text-sm text-muted-foreground", children: config.goal.description }))] }), jsxs(CardContent, { className: "space-y-6", children: [shouldShowProgress && goalTarget > 0 && (jsxs("div", { className: "space-y-2", children: [jsxs("div", { className: "flex justify-between text-sm", children: [jsx("span", { children: "Progress" }), jsxs("span", { children: [formatCurrency(goalCurrent, config.currency), " / ", formatCurrency(goalTarget, config.currency), config.goal?.calculateFromSubscriptions && (jsx("span", { className: "text-muted-foreground ml-1", children: "(MRR)" }))] })] }), jsx(Progress, { value: progressPercentage, className: "h-2" }), jsxs("p", { className: "text-xs text-muted-foreground", children: [Math.round(progressPercentage), "% of monthly goal reached", statsLoading && config.goal?.calculateFromSubscriptions && (jsx("span", { className: "ml-2", children: jsx(Loader2, { className: "h-3 w-3 animate-spin inline" }) }))] })] })), jsxs(Tabs, { value: activeTab, onValueChange: (value) => setActiveTab(value), children: [jsxs(TabsList, { className: "grid w-full grid-cols-2", children: [jsx(TabsTrigger, { value: "monthly", children: "Monthly" }), jsx(TabsTrigger, { value: "annual", children: "Annual" })] }), jsxs(TabsContent, { value: "monthly", className: "space-y-6", children: [jsx("div", { className: "text-center", children: jsxs("div", { className: "text-3xl font-bold text-primary", children: [formatCurrency(monthlyAmount, config.currency), jsx("span", { className: "text-lg font-normal text-muted-foreground", children: "/month" })] }) }), jsxs("div", { className: "space-y-4", children: [jsx(Label, { children: "Choose your monthly support amount" }), jsx("div", { className: "px-2", children: jsx(Slider, { value: [monthlyAmount], onValueChange: (value) => setMonthlyAmount(value[0]), min: getMinAmount("monthly"), max: getMaxAmount("monthly"), step: 0.5, className: "w-full" }) }), jsxs("div", { className: "flex justify-between text-xs text-muted-foreground", children: [jsx("span", { children: formatCurrency(getMinAmount("monthly"), config.currency) }), jsx("span", { children: formatCurrency(getMaxAmount("monthly"), config.currency) })] })] }), jsxs("div", { className: "space-y-2", children: [jsx(Label, { className: "text-sm", children: "Quick amounts" }), jsx("div", { className: "grid grid-cols-2 gap-2", children: getPresetAmounts("monthly").map((amount) => (jsx(Button, { variant: monthlyAmount === amount ? "default" : "outline", size: "sm", onClick: () => setMonthlyAmount(amount), className: "text-sm", children: formatCurrency(amount, config.currency) }, amount))) })] }), jsx(Button, { onClick: () => handleDonate("monthly"), disabled: isLoading, className: "w-full", size: "lg", children: isLoading ? (jsxs(Fragment, { children: [jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), "Processing..."] })) : (`Donate ${formatCurrency(monthlyAmount, config.currency)}/month`) })] }), jsxs(TabsContent, { value: "annual", className: "space-y-6", children: [jsxs("div", { className: "text-center", children: [jsxs("div", { className: "text-3xl font-bold text-primary", children: [formatCurrency(annualAmount, config.currency), jsx("span", { className: "text-lg font-normal text-muted-foreground", children: "/year" })] }), jsxs("div", { className: "text-sm text-muted-foreground mt-1", children: ["Save ", formatCurrency(Math.max(0, (monthlyAmount * 12) - annualAmount), config.currency), " per year"] })] }), jsxs("div", { className: "space-y-4", children: [jsx(Label, { children: "Choose your annual support amount" }), jsx("div", { className: "px-2", children: jsx(Slider, { value: [annualAmount], onValueChange: (value) => setAnnualAmount(value[0]), min: getMinAmount("annual"), max: getMaxAmount("annual"), step: 1, className: "w-full" }) }), jsxs("div", { className: "flex justify-between text-xs text-muted-foreground", children: [jsx("span", { children: formatCurrency(getMinAmount("annual"), config.currency) }), jsx("span", { children: formatCurrency(getMaxAmount("annual"), config.currency) })] })] }), jsxs("div", { className: "space-y-2", children: [jsx(Label, { className: "text-sm", children: "Quick amounts" }), jsx("div", { className: "grid grid-cols-2 gap-2", children: getPresetAmounts("annual").map((amount) => (jsx(Button, { variant: annualAmount === amount ? "default" : "outline", size: "sm", onClick: () => setAnnualAmount(amount), className: "text-sm", children: formatCurrency(amount, config.currency) }, amount))) })] }), jsx(Button, { onClick: () => handleDonate("annual"), disabled: isLoading, className: "w-full", size: "lg", children: isLoading ? (jsxs(Fragment, { children: [jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), "Processing..."] })) : (`Donate ${formatCurrency(annualAmount, config.currency)}/year`) })] })] }), error && (jsx("div", { className: "p-3 bg-red-50 border border-red-200 rounded-md", children: jsx("p", { className: "text-sm text-red-600", children: error }) })), jsx("p", { className: "text-xs text-center text-muted-foreground", children: "Secure payment powered by Stripe" })] })] }));
}
class DonationStripeManager {
constructor(secretKey) {
this.stripe = new Stripe(secretKey, {
apiVersion: '2023-10-16',
});
}
/**
* Ensure donation products exist in Stripe, create them if they don't
*/
async ensureProductsExist(projectSlug, projectName) {
const productIds = generateProductIds(projectSlug);
// Check if products already exist
const existingProducts = await this.stripe.products.list({
ids: [productIds.monthly, productIds.annual],
});
const existingProductMap = new Map(existingProducts.data.map((product) => [product.id, product]));
// Create monthly product if it doesn't exist
let monthlyProduct = existingProductMap.get(productIds.monthly);
if (!monthlyProduct) {
monthlyProduct = await this.stripe.products.create({
id: productIds.monthly,
name: `${projectName} - Monthly Support`,
description: `Monthly recurring donation to support ${projectName}`,
type: 'service',
});
}
// Create annual product if it doesn't exist
let annualProduct = existingProductMap.get(productIds.annual);
if (!annualProduct) {
annualProduct = await this.stripe.products.create({
id: productIds.annual,
name: `${projectName} - Annual Support`,
description: `Annual recurring donation to support ${projectName}`,
type: 'service',
});
}
// Get or create prices for the products
const monthlyPriceId = await this.getOrCreatePrice(monthlyProduct.id, 'month');
const annualPriceId = await this.getOrCreatePrice(annualProduct.id, 'year');
return {
monthlyPriceId,
annualPriceId,
};
}
/**
* Get or create a price for a product
*/
async getOrCreatePrice(productId, interval) {
// Check if a price already exists for this product
const existingPrices = await this.stripe.prices.list({
product: productId,
active: true,
});
const existingPrice = existingPrices.data.find((price) => price.recurring?.interval === interval);
if (existingPrice) {
return existingPrice.id;
}
// Create a new price (we'll use a placeholder amount that gets overridden in checkout)
const price = await this.stripe.prices.create({
product: productId,
currency: 'usd',
recurring: {
interval,
},
unit_amount: 100, // $1.00 placeholder - actual amount set in checkout
});
return price.id;
}
/**
* Create a checkout session for donations
*/
async createCheckoutSession(sessionData) {
// Create checkout session with dynamic pricing
const session = await this.stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: sessionData.type === 'monthly'
? `${sessionData.projectName} - Monthly Support`
: `${sessionData.projectName} - Annual Support`,
description: sessionData.type === 'monthly'
? `Monthly recurring donation to support ${sessionData.projectName}`
: `Annual recurring donation to support ${sessionData.projectName}`,
},
unit_amount: Math.round(sessionData.amount * 100), // Convert to cents
recurring: {
interval: sessionData.type === 'monthly' ? 'month' : 'year',
},
},
quantity: 1,
},
],
success_url: sessionData.successUrl,
cancel_url: sessionData.cancelUrl,
subscription_data: {
metadata: {
projectSlug: sessionData.projectSlug,
donationType: sessionData.type,
amount: sessionData.amount.toString(),
},
},
metadata: {
projectSlug: sessionData.projectSlug,
donationType: sessionData.type,
amount: sessionData.amount.toString(),
},
});
if (!session.url) {
throw new Error('Failed to create checkout session URL');
}
return {
sessionId: session.id,
url: session.url,
};
}
/**
* Handle Stripe webhooks
*/
async handleWebhook(payload, signature, webhookSecret, options) {
let event;
try {
event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
}
catch (err) {
console.error('Webhook signature verification failed:', err);
throw new Error('Invalid webhook signature');
}
// Handle the event
switch (event.type) {
case 'customer.subscription.created':
if (options.onSubscriptionCreated) {
await options.onSubscriptionCreated(event.data.object);
}
break;
case 'customer.subscription.updated':
if (options.onSubscriptionUpdated) {
await options.onSubscriptionUpdated(event.data.object);
}
break;
case 'customer.subscription.deleted':
if (options.onSubscriptionDeleted) {
await options.onSubscriptionDeleted(event.data.object);
}
break;
case 'payment_intent.succeeded':
if (options.onPaymentSucceeded) {
await options.onPaymentSucceeded(event.data.object);
}
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return { received: true };
}
/**
* Get subscription statistics for a project (legacy method)
*/
async getSubscriptionStats(projectSlug) {
const summary = await this.getSubscriptionSummary(projectSlug);
return {
monthlySubscribers: summary.monthlyCount,
annualSubscribers: summary.annualCount,
totalRevenue: summary.monthlyRevenue + summary.annualRevenue,
};
}
/**
* Get detailed subscription summary with MRR calculations
*/
async getSubscriptionSummary(projectSlug) {
// Get all active subscriptions
const subscriptions = await this.stripe.subscriptions.list({
status: 'active',
limit: 100, // Adjust as needed for larger projects
});
let monthlyRevenue = 0;
let annualRevenue = 0;
let monthlyCount = 0;
let annualCount = 0;
// Process each subscription
for (const subscription of subscriptions.data) {
// Check if this subscription belongs to our project by looking at metadata
const belongsToProject = subscription.metadata?.projectSlug === projectSlug;
if (!belongsToProject) {
// Also check by examining the subscription items for our project's products
let foundProjectProduct = false;
for (const item of subscription.items.data) {
const price = await this.stripe.prices.retrieve(item.price.id);
if (typeof price.product === 'string') {
const product = await this.stripe.products.retrieve(price.product);
const productIds = generateProductIds(projectSlug);
if (product.id === productIds.monthly || product.id === productIds.annual) {
foundProjectProduct = true;
break;
}
}
}
if (!foundProjectProduct)
continue;
}
// Calculate revenue from this subscription
for (const item of subscription.items.data) {
const price = await this.stripe.prices.retrieve(item.price.id);
const amount = (price.unit_amount || 0) / 100; // Convert from cents
const quantity = item.quantity || 1;
const totalAmount = amount * quantity;
if (price.recurring?.interval === 'month') {
monthlyRevenue += totalAmount;
monthlyCount++;
}
else if (price.recurring?.interval === 'year') {
annualRevenue += totalAmount;
annualCount++;
}
}
}
// Calculate monthly equivalent of annual subscriptions
const monthlyEquivalent = annualRevenue / 12;
const totalMRR = monthlyRevenue + monthlyEquivalent;
return {
monthlyRevenue,
annualRevenue,
monthlyEquivalent,
totalMRR,
monthlyCount,
annualCount,
};
}
}
/**
* Convenience function to create a checkout session
*/
async function createCheckoutSession(stripeSecretKey, sessionData) {
const manager = new DonationStripeManager(stripeSecretKey);
return manager.createCheckoutSession(sessionData);
}
/**
* Convenience function to handle webhooks
*/
async function handleDonationWebhook(request, options) {
try {
const payload = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('Missing stripe-signature header', { status: 400 });
}
const manager = new DonationStripeManager(options.stripeSecretKey);
const result = await manager.handleWebhook(payload, signature, options.webhookSecret, options);
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
catch (error) {
console.error('Webhook error:', error);
return new Response(JSON.stringify({ error: 'Webhook handler failed' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
}
export { DonationButton, DonationStripeManager, DonationWidget, createCheckoutSession, formatCurrency, generateProductIds, handleDonationWebhook, useDonation, useDonationStats, validateDonationConfig };
//# sourceMappingURL=index.esm.js.map