Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,417 @@
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)
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 20% annual discount
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) {
// Annual price: monthly * 12 * 0.8 (20% discount)
const annualPrice = monthlyPrice * 12 * 0.8;
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 max-w-[385px]">
<h2 className="font-bold text-center text-gray-800 mb-7 text-title-sm dark:text-white/90">
{title}
</h2>
</div>
)}
{showToggle && (
<div className="mb-10 text-center">
<div className="relative inline-flex p-1 mx-auto bg-gray-200 rounded-full z-1 dark:bg-gray-800">
<span
className={`absolute top-1/2 -z-1 flex h-11 w-[120px] -translate-y-1/2 rounded-full bg-white shadow-theme-xs duration-200 ease-linear dark:bg-white/10 ${
billingPeriod === 'monthly' ? 'translate-x-0' : 'translate-x-[120px]'
}`}
></span>
<button
onClick={() => setBillingPeriod('monthly')}
className={`flex h-11 w-[120px] items-center justify-center text-base font-medium transition-colors ${
billingPeriod === 'monthly'
? 'text-gray-800 dark:text-white/90'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
}`}
>
Monthly
</button>
<button
onClick={() => setBillingPeriod('annually')}
className={`flex h-11 w-[120px] items-center justify-center text-base font-medium transition-colors ${
billingPeriod === 'annually'
? 'text-gray-800 dark:text-white/90'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-white/80 dark:text-gray-400'
}`}
>
Annually
</button>
</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 || '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;
}