474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { Check, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
|
||
|
|
import { Card } from '../ui';
|
||
|
|
import type { PlanMetadata, SubscriptionTier } from '../../api';
|
||
|
|
|
||
|
|
type DisplayMode = 'inline' | 'modal';
|
||
|
|
|
||
|
|
interface PlanComparisonTableProps {
|
||
|
|
plans: Record<SubscriptionTier, PlanMetadata>;
|
||
|
|
currentTier?: SubscriptionTier;
|
||
|
|
onSelectPlan?: (tier: SubscriptionTier) => void;
|
||
|
|
mode?: DisplayMode;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FeatureCategory {
|
||
|
|
name: string;
|
||
|
|
features: ComparisonFeature[];
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ComparisonFeature {
|
||
|
|
key: string;
|
||
|
|
name: string;
|
||
|
|
description?: string;
|
||
|
|
starterValue: string | boolean;
|
||
|
|
professionalValue: string | boolean;
|
||
|
|
enterpriseValue: string | boolean;
|
||
|
|
highlight?: boolean; // Highlight Professional-exclusive features
|
||
|
|
}
|
||
|
|
|
||
|
|
export const PlanComparisonTable: React.FC<PlanComparisonTableProps> = ({
|
||
|
|
plans,
|
||
|
|
currentTier,
|
||
|
|
onSelectPlan,
|
||
|
|
mode = 'inline',
|
||
|
|
className = ''
|
||
|
|
}) => {
|
||
|
|
const { t } = useTranslation('subscription');
|
||
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['limits']));
|
||
|
|
|
||
|
|
const toggleCategory = (category: string) => {
|
||
|
|
const newExpanded = new Set(expandedCategories);
|
||
|
|
if (newExpanded.has(category)) {
|
||
|
|
newExpanded.delete(category);
|
||
|
|
} else {
|
||
|
|
newExpanded.add(category);
|
||
|
|
}
|
||
|
|
setExpandedCategories(newExpanded);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Define feature categories with comparison data
|
||
|
|
const featureCategories: FeatureCategory[] = [
|
||
|
|
{
|
||
|
|
name: t('categories.daily_operations'),
|
||
|
|
features: [
|
||
|
|
{
|
||
|
|
key: 'inventory_management',
|
||
|
|
name: t('features.inventory_management'),
|
||
|
|
starterValue: true,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'sales_tracking',
|
||
|
|
name: t('features.sales_tracking'),
|
||
|
|
starterValue: true,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'production_planning',
|
||
|
|
name: t('features.production_planning'),
|
||
|
|
starterValue: true,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'order_management',
|
||
|
|
name: t('features.order_management'),
|
||
|
|
starterValue: true,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'supplier_management',
|
||
|
|
name: t('features.supplier_management'),
|
||
|
|
starterValue: true,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: t('categories.smart_forecasting'),
|
||
|
|
features: [
|
||
|
|
{
|
||
|
|
key: 'basic_forecasting',
|
||
|
|
name: t('features.basic_forecasting'),
|
||
|
|
starterValue: '7 days',
|
||
|
|
professionalValue: '90 days',
|
||
|
|
enterpriseValue: '365 days',
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'demand_prediction',
|
||
|
|
name: t('features.demand_prediction'),
|
||
|
|
starterValue: true,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'seasonal_patterns',
|
||
|
|
name: t('features.seasonal_patterns'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'weather_data_integration',
|
||
|
|
name: t('features.weather_data_integration'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'traffic_data_integration',
|
||
|
|
name: t('features.traffic_data_integration'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'scenario_modeling',
|
||
|
|
name: t('features.scenario_modeling'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'what_if_analysis',
|
||
|
|
name: t('features.what_if_analysis'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: t('categories.business_insights'),
|
||
|
|
features: [
|
||
|
|
{
|
||
|
|
key: 'basic_reporting',
|
||
|
|
name: t('features.basic_reporting'),
|
||
|
|
starterValue: true,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'advanced_analytics',
|
||
|
|
name: t('features.advanced_analytics'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'profitability_analysis',
|
||
|
|
name: t('features.profitability_analysis'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'waste_analysis',
|
||
|
|
name: t('features.waste_analysis'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: t('categories.multi_location'),
|
||
|
|
features: [
|
||
|
|
{
|
||
|
|
key: 'multi_location_support',
|
||
|
|
name: t('features.multi_location_support'),
|
||
|
|
starterValue: '1',
|
||
|
|
professionalValue: '3',
|
||
|
|
enterpriseValue: t('limits.unlimited'),
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'location_comparison',
|
||
|
|
name: t('features.location_comparison'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'inventory_transfer',
|
||
|
|
name: t('features.inventory_transfer'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: t('categories.integrations'),
|
||
|
|
features: [
|
||
|
|
{
|
||
|
|
key: 'pos_integration',
|
||
|
|
name: t('features.pos_integration'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'accounting_export',
|
||
|
|
name: t('features.accounting_export'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: true,
|
||
|
|
enterpriseValue: true,
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'api_access',
|
||
|
|
name: 'API Access',
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: 'Basic',
|
||
|
|
enterpriseValue: 'Full',
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'erp_integration',
|
||
|
|
name: t('features.erp_integration'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: false,
|
||
|
|
enterpriseValue: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'sso_saml',
|
||
|
|
name: t('features.sso_saml'),
|
||
|
|
starterValue: false,
|
||
|
|
professionalValue: false,
|
||
|
|
enterpriseValue: true
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
];
|
||
|
|
|
||
|
|
// Limits comparison
|
||
|
|
const limitsCategory: FeatureCategory = {
|
||
|
|
name: 'Limits & Quotas',
|
||
|
|
features: [
|
||
|
|
{
|
||
|
|
key: 'users',
|
||
|
|
name: t('limits.users'),
|
||
|
|
starterValue: '5',
|
||
|
|
professionalValue: '20',
|
||
|
|
enterpriseValue: t('limits.unlimited'),
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'locations',
|
||
|
|
name: t('limits.locations'),
|
||
|
|
starterValue: '1',
|
||
|
|
professionalValue: '3',
|
||
|
|
enterpriseValue: t('limits.unlimited'),
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'products',
|
||
|
|
name: t('limits.products'),
|
||
|
|
starterValue: '50',
|
||
|
|
professionalValue: '500',
|
||
|
|
enterpriseValue: t('limits.unlimited'),
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'training_jobs',
|
||
|
|
name: 'Training Jobs/Day',
|
||
|
|
starterValue: '1',
|
||
|
|
professionalValue: '5',
|
||
|
|
enterpriseValue: t('limits.unlimited'),
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'forecasts',
|
||
|
|
name: 'Forecasts/Day',
|
||
|
|
starterValue: '10',
|
||
|
|
professionalValue: '100',
|
||
|
|
enterpriseValue: t('limits.unlimited'),
|
||
|
|
highlight: true
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'api_calls',
|
||
|
|
name: 'API Calls/Hour',
|
||
|
|
starterValue: '100',
|
||
|
|
professionalValue: '1,000',
|
||
|
|
enterpriseValue: '10,000',
|
||
|
|
highlight: true
|
||
|
|
}
|
||
|
|
]
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderValue = (value: string | boolean, tierKey: string) => {
|
||
|
|
const isProfessional = tierKey === 'professional';
|
||
|
|
|
||
|
|
if (typeof value === 'boolean') {
|
||
|
|
return value ? (
|
||
|
|
<Check className={`w-5 h-5 mx-auto ${isProfessional ? 'text-emerald-500' : 'text-green-500'}`} />
|
||
|
|
) : (
|
||
|
|
<X className="w-5 h-5 mx-auto text-gray-400" />
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<span className={`font-semibold ${isProfessional ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-700 dark:text-gray-300'}`}>
|
||
|
|
{value}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const allCategories = [limitsCategory, ...featureCategories];
|
||
|
|
|
||
|
|
// Conditional wrapper based on mode
|
||
|
|
const Wrapper = mode === 'inline' ? Card : 'div';
|
||
|
|
const wrapperProps = mode === 'inline' ? { className: `p-6 overflow-hidden ${className}` } : { className };
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Wrapper {...wrapperProps}>
|
||
|
|
{mode === 'inline' && (
|
||
|
|
<div className="mb-6">
|
||
|
|
<h2 className="text-2xl font-bold text-[var(--text-primary)] mb-2">
|
||
|
|
{t('ui.compare_plans')}
|
||
|
|
</h2>
|
||
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
||
|
|
{t('ui.detailed_feature_comparison')}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Add padding-top to prevent Best Value badge from being cut off */}
|
||
|
|
<div className="overflow-x-auto pt-6">
|
||
|
|
<table className="w-full border-collapse">
|
||
|
|
{/* Header */}
|
||
|
|
<thead>
|
||
|
|
<tr className="border-b-2 border-[var(--border-primary)]">
|
||
|
|
<th className="text-left py-4 px-4 font-semibold text-[var(--text-primary)]">
|
||
|
|
{t('ui.feature')}
|
||
|
|
</th>
|
||
|
|
<th className="text-center py-4 px-4 w-1/4">
|
||
|
|
<div className="font-semibold text-[var(--text-primary)]">Starter</div>
|
||
|
|
<div className="text-xs text-[var(--text-secondary)] mt-1">€49/mo</div>
|
||
|
|
</th>
|
||
|
|
<th className="text-center py-4 px-4 w-1/4 bg-gradient-to-b from-blue-50 to-blue-100/50 dark:from-blue-900/20 dark:to-blue-900/10 relative">
|
||
|
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 whitespace-nowrap">
|
||
|
|
<span className="bg-gradient-to-r from-emerald-500 to-green-600 text-white px-3 py-1 rounded-full text-xs font-bold shadow-lg flex items-center gap-1">
|
||
|
|
<Sparkles className="w-3 h-3" />
|
||
|
|
{t('ui.best_value')}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="font-semibold text-blue-700 dark:text-blue-300">Professional</div>
|
||
|
|
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 font-medium">€149/mo</div>
|
||
|
|
</th>
|
||
|
|
<th className="text-center py-4 px-4 w-1/4">
|
||
|
|
<div className="font-semibold text-[var(--text-primary)]">Enterprise</div>
|
||
|
|
<div className="text-xs text-[var(--text-secondary)] mt-1">€499/mo</div>
|
||
|
|
</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
|
||
|
|
{/* Body with collapsible categories */}
|
||
|
|
<tbody>
|
||
|
|
{allCategories.map((category) => (
|
||
|
|
<React.Fragment key={category.name}>
|
||
|
|
{/* Category Header */}
|
||
|
|
<tr
|
||
|
|
className="bg-[var(--bg-secondary)] border-t-2 border-[var(--border-primary)] cursor-pointer hover:bg-[var(--bg-primary)] transition-colors"
|
||
|
|
onClick={() => toggleCategory(category.name)}
|
||
|
|
>
|
||
|
|
<td colSpan={4} className="py-3 px-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<span className="font-bold text-[var(--text-primary)] uppercase text-sm tracking-wide">
|
||
|
|
{category.name}
|
||
|
|
</span>
|
||
|
|
{expandedCategories.has(category.name) ? (
|
||
|
|
<ChevronUp className="w-5 h-5 text-[var(--text-secondary)]" />
|
||
|
|
) : (
|
||
|
|
<ChevronDown className="w-5 h-5 text-[var(--text-secondary)]" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
|
||
|
|
{/* Category Features */}
|
||
|
|
{expandedCategories.has(category.name) && category.features.map((feature, idx) => (
|
||
|
|
<tr
|
||
|
|
key={feature.key}
|
||
|
|
className={`border-b border-[var(--border-primary)] hover:bg-[var(--bg-primary)] transition-colors ${
|
||
|
|
feature.highlight ? 'bg-blue-50/30 dark:bg-blue-900/10' : ''
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<td className="py-3 px-4">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{feature.highlight && (
|
||
|
|
<Sparkles className="w-4 h-4 text-emerald-500 flex-shrink-0" />
|
||
|
|
)}
|
||
|
|
<span className="text-sm text-[var(--text-primary)]">{feature.name}</span>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td className="py-3 px-4 text-center">
|
||
|
|
{renderValue(feature.starterValue, 'starter')}
|
||
|
|
</td>
|
||
|
|
<td className="py-3 px-4 text-center bg-blue-50/50 dark:bg-blue-900/5">
|
||
|
|
{renderValue(feature.professionalValue, 'professional')}
|
||
|
|
</td>
|
||
|
|
<td className="py-3 px-4 text-center">
|
||
|
|
{renderValue(feature.enterpriseValue, 'enterprise')}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</React.Fragment>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Footer with CTA - only show in inline mode */}
|
||
|
|
{mode === 'inline' && onSelectPlan && (
|
||
|
|
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
|
|
<div className="text-center">
|
||
|
|
<button
|
||
|
|
onClick={() => onSelectPlan('starter' as SubscriptionTier)}
|
||
|
|
className="w-full px-6 py-3 border-2 border-[var(--color-primary)] text-[var(--color-primary)] rounded-lg font-semibold hover:bg-[var(--color-primary)] hover:text-white transition-colors"
|
||
|
|
>
|
||
|
|
{t('ui.choose_starter')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div className="text-center">
|
||
|
|
<button
|
||
|
|
onClick={() => onSelectPlan('professional' as SubscriptionTier)}
|
||
|
|
className="w-full px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold shadow-lg hover:shadow-xl hover:from-blue-700 hover:to-blue-800 transition-all"
|
||
|
|
>
|
||
|
|
{t('ui.choose_professional')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div className="text-center">
|
||
|
|
<button
|
||
|
|
onClick={() => onSelectPlan('enterprise' as SubscriptionTier)}
|
||
|
|
className="w-full px-6 py-3 border-2 border-[var(--color-primary)] text-[var(--color-primary)] rounded-lg font-semibold hover:bg-[var(--color-primary)] hover:text-white transition-colors"
|
||
|
|
>
|
||
|
|
{t('ui.choose_enterprise')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Wrapper>
|
||
|
|
);
|
||
|
|
};
|