Files
igny8/frontend/src/components/ui/pricing-table/PricingTable.tsx
IGNY8 VPS (Salman) db1fd2fff8 nbcvhc
2025-12-13 13:43:55 +00:00

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;
}