442 lines
19 KiB
TypeScript
442 lines
19 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
export interface PricingPlan {
|
|
id?: number;
|
|
name: string;
|
|
price: string | number; // Current displayed price (will be calculated based on period)
|
|
monthlyPrice?: string | number; // Base monthly price (used for annual discount calculation)
|
|
annualDiscountPercent?: number; // Annual discount percentage from backend (default 15%)
|
|
originalPrice?: string | number;
|
|
period?: string; // "/month", "/year", "/Lifetime"
|
|
description?: string;
|
|
features: string[];
|
|
buttonText?: string;
|
|
highlighted?: boolean; // For featured/popular plan
|
|
icon?: React.ReactNode;
|
|
disabled?: boolean;
|
|
recommended?: boolean; // For "Recommended" badge
|
|
}
|
|
|
|
export interface PricingTableProps {
|
|
variant?: '1' | '2' | '3'; // Three different table styles
|
|
title?: string;
|
|
subtitle?: string;
|
|
plans: PricingPlan[];
|
|
showToggle?: boolean; // Monthly/Annually toggle
|
|
onPlanSelect?: (plan: PricingPlan) => void;
|
|
className?: string;
|
|
}
|
|
|
|
// Checkmark SVG Icon
|
|
const CheckIcon = () => (
|
|
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-success-500">
|
|
<path d="M13.4017 4.35986L6.12166 11.6399L2.59833 8.11657" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"></path>
|
|
</svg>
|
|
);
|
|
|
|
// X Icon for excluded features
|
|
const XIcon = () => (
|
|
<svg width="1em" height="1em" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-gray-400">
|
|
<path fillRule="evenodd" clipRule="evenodd" d="M4.05394 4.78033C3.76105 4.48744 3.76105 4.01256 4.05394 3.71967C4.34684 3.42678 4.82171 3.42678 5.1146 3.71967L8.33437 6.93944L11.5521 3.72173C11.845 3.42883 12.3199 3.42883 12.6127 3.72173C12.9056 4.01462 12.9056 4.48949 12.6127 4.78239L9.39503 8.0001L12.6127 11.2178C12.9056 11.5107 12.9056 11.9856 12.6127 12.2785C12.3198 12.5713 11.845 12.5713 11.5521 12.2785L8.33437 9.06076L5.11462 12.2805C4.82173 12.5734 4.34685 12.5734 4.05396 12.2805C3.76107 11.9876 3.76107 11.5127 4.05396 11.2199L7.27371 8.0001L4.05394 4.78033Z" fill="currentColor"></path>
|
|
</svg>
|
|
);
|
|
|
|
export default function PricingTable({
|
|
variant = '1',
|
|
title,
|
|
subtitle,
|
|
plans,
|
|
showToggle = false,
|
|
onPlanSelect,
|
|
className = '',
|
|
}: PricingTableProps) {
|
|
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annually'>('monthly');
|
|
|
|
const handlePlanClick = (plan: PricingPlan) => {
|
|
if (plan.disabled) return;
|
|
onPlanSelect?.(plan);
|
|
};
|
|
|
|
const formatPrice = (price: string | number) => {
|
|
if (typeof price === 'number') {
|
|
return price.toFixed(2);
|
|
}
|
|
return price;
|
|
};
|
|
|
|
// Calculate price based on billing period with discount from backend
|
|
const getDisplayPrice = (plan: PricingPlan): { price: number; originalPrice?: number } => {
|
|
const monthlyPrice = typeof plan.monthlyPrice === 'number'
|
|
? plan.monthlyPrice
|
|
: typeof plan.price === 'number'
|
|
? plan.price
|
|
: parseFloat(String(plan.price || 0));
|
|
|
|
if (billingPeriod === 'annually' && showToggle) {
|
|
// Get discount percentage from plan (default 15%)
|
|
const discountPercent = plan.annualDiscountPercent || 15;
|
|
const discountMultiplier = (100 - discountPercent) / 100;
|
|
|
|
// Annual price: monthly * 12 * discount multiplier
|
|
const annualPrice = monthlyPrice * 12 * discountMultiplier;
|
|
const originalAnnualPrice = monthlyPrice * 12;
|
|
return { price: annualPrice, originalPrice: originalAnnualPrice };
|
|
}
|
|
|
|
// Monthly price
|
|
return { price: monthlyPrice };
|
|
};
|
|
|
|
// Variant 1: With toggle and highlighted center card
|
|
if (variant === '1') {
|
|
return (
|
|
<div className={`space-y-6 ${className}`}>
|
|
{title && (
|
|
<div className="mx-auto w-full">
|
|
<h2 className="font-bold text-center text-gray-800 text-2xl mb-5 dark:text-white/90">
|
|
{title}
|
|
</h2>
|
|
</div>
|
|
)}
|
|
{showToggle && (
|
|
<div className="mb-8 text-center">
|
|
<div className="relative inline-flex p-1 bg-gray-200 rounded-full dark:bg-gray-800 shadow-sm">
|
|
<span
|
|
className="absolute top-1 left-1 flex h-11 w-[130px] rounded-full shadow-theme-xs duration-200 ease-linear"
|
|
style={{
|
|
background: 'linear-gradient(to bottom right, #0693e3, #0472b8)',
|
|
transform: billingPeriod === 'monthly' ? 'translateX(0)' : 'translateX(130px)',
|
|
}}
|
|
></span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setBillingPeriod('monthly')}
|
|
className={`relative z-10 flex h-11 w-[130px] items-center justify-center font-medium transition-all duration-200 rounded-full ${
|
|
billingPeriod === 'monthly'
|
|
? 'text-white'
|
|
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
Monthly
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setBillingPeriod('annually')}
|
|
className={`relative z-10 flex h-11 w-[130px] items-center justify-center font-medium transition-all duration-200 rounded-full ${
|
|
billingPeriod === 'annually'
|
|
? 'text-white'
|
|
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
Annually
|
|
</button>
|
|
</div>
|
|
{billingPeriod === 'annually' && (
|
|
<div className="flex items-center justify-center mt-3">
|
|
<span className="inline-flex items-center gap-1.5 text-green-600 dark:text-green-400 font-semibold bg-green-50 dark:bg-green-900/20 px-3 py-1.5 rounded-full text-sm">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
Save 15% with annual billing
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:gap-6">
|
|
{plans.map((plan, index) => {
|
|
const isHighlighted = plan.highlighted || false; // Use explicit highlighted prop
|
|
const displayPrice = getDisplayPrice(plan);
|
|
const period = billingPeriod === 'annually' && showToggle ? '/year' : (plan.period || '/month');
|
|
|
|
return (
|
|
<div
|
|
key={plan.id || index}
|
|
className={`rounded-2xl border p-6 flex flex-col ${
|
|
isHighlighted
|
|
? 'bg-gray-800 border-gray-800 dark:border-white/10 dark:bg-white/10'
|
|
: 'border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`block mb-3 font-semibold text-theme-xl ${
|
|
isHighlighted ? 'text-white' : 'text-gray-800 dark:text-white/90'
|
|
}`}
|
|
>
|
|
{plan.name}
|
|
</span>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-end">
|
|
<h2
|
|
className={`font-bold text-title-md ${
|
|
isHighlighted ? 'text-white' : 'text-gray-800 dark:text-white/90'
|
|
}`}
|
|
>
|
|
${formatPrice(displayPrice.price)}
|
|
</h2>
|
|
<span
|
|
className={`inline-block mb-1 text-sm ${
|
|
isHighlighted ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{period}
|
|
</span>
|
|
</div>
|
|
{(displayPrice.originalPrice || plan.originalPrice) && (
|
|
<span
|
|
className={`font-semibold line-through text-theme-xl ${
|
|
isHighlighted ? 'text-gray-300' : 'text-gray-400'
|
|
}`}
|
|
>
|
|
${formatPrice(displayPrice.originalPrice || plan.originalPrice || 0)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{plan.description && (
|
|
<p
|
|
className={`text-sm ${
|
|
isHighlighted ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{plan.description}
|
|
</p>
|
|
)}
|
|
<div className={`w-full h-px my-6 ${isHighlighted ? 'bg-white/20' : 'bg-gray-200 dark:bg-gray-800'}`}></div>
|
|
<ul className="mb-8 space-y-3 flex-grow">
|
|
{plan.features.map((feature, idx) => {
|
|
const isExcluded = feature.startsWith('!');
|
|
const featureText = isExcluded ? feature.substring(1) : feature;
|
|
return (
|
|
<li
|
|
key={idx}
|
|
className={`flex items-center gap-3 text-sm ${
|
|
isHighlighted
|
|
? 'text-white/80'
|
|
: isExcluded
|
|
? 'text-gray-400'
|
|
: 'text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{isExcluded ? <XIcon /> : <CheckIcon />}
|
|
{featureText}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<button
|
|
onClick={() => handlePlanClick(plan)}
|
|
disabled={plan.disabled}
|
|
className={`flex w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium text-white shadow-theme-xs transition-colors mt-auto ${
|
|
isHighlighted
|
|
? 'bg-brand-500 hover:bg-brand-600 dark:hover:bg-brand-600'
|
|
: 'bg-gray-800 hover:bg-brand-500 dark:bg-white/10 dark:hover:bg-brand-600'
|
|
} ${plan.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
{plan.buttonText || (plan.price === 0 || plan.monthlyPrice === 0 ? 'Start Free' : 'Choose Plan')}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Variant 2: With icons and border highlight
|
|
if (variant === '2') {
|
|
return (
|
|
<div className={`space-y-6 ${className}`}>
|
|
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
|
{plans.map((plan, index) => {
|
|
const isHighlighted = plan.highlighted || index === 1;
|
|
return (
|
|
<div
|
|
key={plan.id || index}
|
|
className={`rounded-2xl border p-6 xl:p-8 ${
|
|
isHighlighted
|
|
? 'border-2 border-brand-500 bg-white dark:border-brand-500 dark:bg-white/[0.03]'
|
|
: 'border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between -mb-4">
|
|
<span className="block font-semibold text-gray-800 text-theme-xl dark:text-white/90">
|
|
{plan.name}
|
|
</span>
|
|
{plan.icon && (
|
|
<span className="flex h-[56px] dark:bg-brand-500/10 w-[56px] items-center justify-center rounded-[10.5px] bg-brand-50 text-brand-500">
|
|
{plan.icon}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-end">
|
|
<h2 className="font-bold text-gray-800 text-title-md dark:text-white/90">
|
|
${formatPrice(plan.price)}
|
|
</h2>
|
|
<span className="inline-block mb-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{plan.period || ' / Lifetime'}
|
|
</span>
|
|
</div>
|
|
{plan.description && (
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{plan.description}</p>
|
|
)}
|
|
<div className="w-full h-px my-6 bg-gray-200 dark:bg-gray-800"></div>
|
|
<ul className="mb-8 space-y-3">
|
|
{plan.features.map((feature, idx) => {
|
|
const isExcluded = feature.startsWith('!');
|
|
const featureText = isExcluded ? feature.substring(1) : feature;
|
|
return (
|
|
<li
|
|
key={idx}
|
|
className={`flex items-center gap-3 text-sm ${
|
|
isExcluded ? 'text-gray-400' : 'text-gray-700 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{isExcluded ? <XIcon /> : <CheckIcon />}
|
|
{featureText}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<button
|
|
onClick={() => handlePlanClick(plan)}
|
|
disabled={plan.disabled}
|
|
className={`flex w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium text-white shadow-theme-xs transition-colors ${
|
|
isHighlighted
|
|
? 'bg-brand-500 hover:bg-brand-600'
|
|
: 'bg-gray-800 hover:bg-brand-500 dark:bg-white/10 dark:hover:bg-brand-600'
|
|
} ${plan.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
{plan.buttonText || (isHighlighted ? 'Choose This Plan' : 'Choose Starter')}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Variant 3: Compact with recommended badge
|
|
if (variant === '3') {
|
|
return (
|
|
<div className={`space-y-6 ${className}`}>
|
|
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 xl:gap-3 2xl:grid-cols-4">
|
|
{plans.map((plan, index) => {
|
|
const isRecommended = plan.recommended || index === 2;
|
|
return (
|
|
<div key={plan.id || index}>
|
|
<div
|
|
className={`rounded-2xl p-6 ${
|
|
isRecommended
|
|
? 'relative bg-brand-500'
|
|
: 'bg-white dark:bg-white/[0.03]'
|
|
}`}
|
|
>
|
|
{isRecommended && (
|
|
<div className="absolute px-3 py-1 font-medium text-white rounded-lg right-4 top-4 -z-1 bg-white/10 text-theme-xs">
|
|
Recommended
|
|
</div>
|
|
)}
|
|
<span
|
|
className={`block font-semibold text-theme-xl ${
|
|
isRecommended ? 'text-white' : 'text-gray-800 dark:text-white/90'
|
|
}`}
|
|
>
|
|
{plan.name}
|
|
</span>
|
|
{plan.description && (
|
|
<p
|
|
className={`mt-1 text-sm ${
|
|
isRecommended ? 'text-white/90' : 'text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{plan.description}
|
|
</p>
|
|
)}
|
|
<h2
|
|
className={`mb-0.5 mt-4 text-title-sm font-bold ${
|
|
isRecommended ? 'text-white' : 'text-gray-800 dark:text-white/90'
|
|
}`}
|
|
>
|
|
{typeof plan.price === 'string' && plan.price.toLowerCase() === 'free'
|
|
? 'Free'
|
|
: `$${formatPrice(plan.price)}`}
|
|
</h2>
|
|
<span
|
|
className={`inline-block mb-6 text-sm ${
|
|
isRecommended ? 'text-white/90' : 'text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{plan.period || 'For a Lifetime'}
|
|
</span>
|
|
<button
|
|
onClick={() => handlePlanClick(plan)}
|
|
disabled={plan.disabled}
|
|
className={`flex h-11 w-full items-center justify-center rounded-lg p-3.5 text-sm font-medium shadow-theme-xs transition-colors ${
|
|
isRecommended
|
|
? 'bg-white text-gray-800 hover:bg-gray-50'
|
|
: plan.disabled
|
|
? 'border border-gray-300 bg-white text-gray-400 disabled:pointer-events-none dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-white/[0.03] dark:hover:text-gray-200'
|
|
: 'bg-brand-500 text-white hover:bg-brand-600'
|
|
}`}
|
|
>
|
|
{plan.buttonText || (plan.disabled ? 'Current Plan' : 'Try for Free')}
|
|
</button>
|
|
<ul className="mt-6 space-y-3">
|
|
{plan.features.map((feature, idx) => {
|
|
const isExcluded = feature.startsWith('!');
|
|
const featureText = isExcluded ? feature.substring(1) : feature;
|
|
return (
|
|
<li
|
|
key={idx}
|
|
className={`flex items-center gap-3 text-sm ${
|
|
isRecommended
|
|
? 'text-white'
|
|
: isExcluded
|
|
? 'text-gray-400'
|
|
: 'text-gray-700 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{isExcluded ? (
|
|
<XIcon />
|
|
) : (
|
|
<svg
|
|
width="1em"
|
|
height="1em"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className={isRecommended ? 'text-white' : 'text-success-500'}
|
|
>
|
|
<path
|
|
d="M13.4017 4.35986L6.12166 11.6399L2.59833 8.11657"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
></path>
|
|
</svg>
|
|
)}
|
|
{featureText}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|