Add TenantCustomTier system and fix BusinessEditModal feature loading

Backend:
- Add TenantCustomTier model for per-tenant feature overrides
- Update EntitlementService to check custom tier before plan features
- Add custom_tier action on TenantViewSet (GET/PUT/DELETE)
- Add Celery task for grace period management (30-day expiry)

Frontend:
- Add DynamicFeaturesEditor component for dynamic feature management
- Fix BusinessEditModal to load features from plan defaults when no custom tier
- Update limits (max_users, max_resources, etc.) to use featureValues
- Remove outdated canonical feature check from FeaturePicker (removes warning icons)
- Add useBillingPlans hook for accessing billing system data
- Add custom tier API functions to platform.ts

Features now follow consistent rules:
- Load from plan defaults when no custom tier exists
- Load from custom tier when one exists
- Reset to plan defaults when plan changes
- Save to custom tier on edit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-12 21:00:54 -05:00
parent d25c578e59
commit b384d9912a
183 changed files with 47627 additions and 3955 deletions

View File

@@ -0,0 +1,242 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Check, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePublicPlans,
formatPrice,
PublicPlanVersion,
} from '../../hooks/usePublicPlans';
interface DynamicPricingCardsProps {
className?: string;
}
const DynamicPricingCards: React.FC<DynamicPricingCardsProps> = ({ className = '' }) => {
const { t } = useTranslation();
const { data: plans, isLoading, error } = usePublicPlans();
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly');
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
if (error || !plans) {
return (
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
{t('marketing.pricing.loadError', 'Unable to load pricing. Please try again later.')}
</div>
);
}
// Sort plans by display_order
const sortedPlans = [...plans].sort(
(a, b) => a.plan.display_order - b.plan.display_order
);
return (
<div className={className}>
{/* Billing Toggle */}
<div className="flex justify-center mb-12">
<div className="bg-gray-100 dark:bg-gray-800 p-1 rounded-lg inline-flex">
<button
onClick={() => setBillingPeriod('monthly')}
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
billingPeriod === 'monthly'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('marketing.pricing.monthly', 'Monthly')}
</button>
<button
onClick={() => setBillingPeriod('annual')}
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
billingPeriod === 'annual'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('marketing.pricing.annual', 'Annual')}
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-semibold">
{t('marketing.pricing.savePercent', 'Save ~17%')}
</span>
</button>
</div>
</div>
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{sortedPlans.map((planVersion) => (
<PlanCard
key={planVersion.id}
planVersion={planVersion}
billingPeriod={billingPeriod}
/>
))}
</div>
</div>
);
};
interface PlanCardProps {
planVersion: PublicPlanVersion;
billingPeriod: 'monthly' | 'annual';
}
const PlanCard: React.FC<PlanCardProps> = ({ planVersion, billingPeriod }) => {
const { t } = useTranslation();
const { plan, is_most_popular, show_price, marketing_features, trial_days } = planVersion;
const price =
billingPeriod === 'annual'
? planVersion.price_yearly_cents
: planVersion.price_monthly_cents;
const isEnterprise = !show_price || plan.code === 'enterprise';
const isFree = price === 0 && plan.code === 'free';
// Determine CTA
const ctaLink = isEnterprise ? '/contact' : `/signup?plan=${plan.code}`;
const ctaText = isEnterprise
? t('marketing.pricing.contactSales', 'Contact Sales')
: isFree
? t('marketing.pricing.getStartedFree', 'Get Started Free')
: t('marketing.pricing.startTrial', 'Start Free Trial');
if (is_most_popular) {
return (
<div className="relative flex flex-col p-6 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20 transform lg:scale-105 z-10">
{/* Most Popular Badge */}
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-brand-500 text-white text-xs font-semibold rounded-full whitespace-nowrap">
{t('marketing.pricing.mostPopular', 'Most Popular')}
</div>
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-white mb-1">{plan.name}</h3>
<p className="text-brand-100 text-sm">{plan.description}</p>
</div>
{/* Price */}
<div className="mb-4">
{isEnterprise ? (
<span className="text-3xl font-bold text-white">
{t('marketing.pricing.custom', 'Custom')}
</span>
) : (
<>
<span className="text-4xl font-bold text-white">
{formatPrice(price)}
</span>
<span className="text-brand-200 ml-1 text-sm">
{billingPeriod === 'annual'
? t('marketing.pricing.perYear', '/year')
: t('marketing.pricing.perMonth', '/month')}
</span>
</>
)}
{trial_days > 0 && !isFree && (
<div className="mt-1 text-xs text-brand-100">
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
days: trial_days,
})}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-2 mb-6">
{marketing_features.map((feature, index) => (
<li key={index} className="flex items-start gap-2">
<Check className="h-4 w-4 text-brand-200 flex-shrink-0 mt-0.5" />
<span className="text-white text-sm">{feature}</span>
</li>
))}
</ul>
{/* CTA */}
<Link
to={ctaLink}
className="block w-full py-3 px-4 text-center text-sm font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
>
{ctaText}
</Link>
</div>
);
}
return (
<div className="relative flex flex-col p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
{plan.name}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{plan.description}
</p>
</div>
{/* Price */}
<div className="mb-4">
{isEnterprise ? (
<span className="text-3xl font-bold text-gray-900 dark:text-white">
{t('marketing.pricing.custom', 'Custom')}
</span>
) : (
<>
<span className="text-4xl font-bold text-gray-900 dark:text-white">
{formatPrice(price)}
</span>
<span className="text-gray-500 dark:text-gray-400 ml-1 text-sm">
{billingPeriod === 'annual'
? t('marketing.pricing.perYear', '/year')
: t('marketing.pricing.perMonth', '/month')}
</span>
</>
)}
{trial_days > 0 && !isFree && (
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
days: trial_days,
})}
</div>
)}
{isFree && (
<div className="mt-1 text-xs text-green-600 dark:text-green-400">
{t('marketing.pricing.freeForever', 'Free forever')}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-2 mb-6">
{marketing_features.map((feature, index) => (
<li key={index} className="flex items-start gap-2">
<Check className="h-4 w-4 text-brand-600 dark:text-brand-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-700 dark:text-gray-300 text-sm">{feature}</span>
</li>
))}
</ul>
{/* CTA */}
<Link
to={ctaLink}
className={`block w-full py-3 px-4 text-center text-sm font-semibold rounded-xl transition-colors ${
isFree
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 hover:bg-brand-100 dark:hover:bg-brand-900/50'
}`}
>
{ctaText}
</Link>
</div>
);
};
export default DynamicPricingCards;

View File

@@ -0,0 +1,251 @@
import React from 'react';
import { Check, X, Minus, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePublicPlans,
PublicPlanVersion,
getPlanFeatureValue,
formatLimit,
} from '../../hooks/usePublicPlans';
// Feature categories for the comparison table
const FEATURE_CATEGORIES = [
{
key: 'limits',
features: [
{ code: 'max_users', label: 'Team members' },
{ code: 'max_resources', label: 'Resources' },
{ code: 'max_locations', label: 'Locations' },
{ code: 'max_services', label: 'Services' },
{ code: 'max_customers', label: 'Customers' },
{ code: 'max_appointments_per_month', label: 'Appointments/month' },
],
},
{
key: 'communication',
features: [
{ code: 'email_enabled', label: 'Email notifications' },
{ code: 'max_email_per_month', label: 'Emails/month' },
{ code: 'sms_enabled', label: 'SMS reminders' },
{ code: 'max_sms_per_month', label: 'SMS/month' },
{ code: 'masked_calling_enabled', label: 'Masked calling' },
],
},
{
key: 'booking',
features: [
{ code: 'online_booking', label: 'Online booking' },
{ code: 'recurring_appointments', label: 'Recurring appointments' },
{ code: 'payment_processing', label: 'Accept payments' },
{ code: 'mobile_app_access', label: 'Mobile app' },
],
},
{
key: 'integrations',
features: [
{ code: 'integrations_enabled', label: 'Third-party integrations' },
{ code: 'api_access', label: 'API access' },
{ code: 'max_api_calls_per_day', label: 'API calls/day' },
],
},
{
key: 'branding',
features: [
{ code: 'custom_domain', label: 'Custom domain' },
{ code: 'custom_branding', label: 'Custom branding' },
{ code: 'remove_branding', label: 'Remove "Powered by"' },
{ code: 'white_label', label: 'White label' },
],
},
{
key: 'enterprise',
features: [
{ code: 'multi_location', label: 'Multi-location management' },
{ code: 'team_permissions', label: 'Team permissions' },
{ code: 'audit_logs', label: 'Audit logs' },
{ code: 'advanced_reporting', label: 'Advanced analytics' },
],
},
{
key: 'support',
features: [
{ code: 'priority_support', label: 'Priority support' },
{ code: 'dedicated_account_manager', label: 'Dedicated account manager' },
{ code: 'sla_guarantee', label: 'SLA guarantee' },
],
},
{
key: 'storage',
features: [
{ code: 'max_storage_mb', label: 'File storage' },
],
},
];
interface FeatureComparisonTableProps {
className?: string;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
className = '',
}) => {
const { t } = useTranslation();
const { data: plans, isLoading, error } = usePublicPlans();
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
if (error || !plans || plans.length === 0) {
return null;
}
// Sort plans by display_order
const sortedPlans = [...plans].sort(
(a, b) => a.plan.display_order - b.plan.display_order
);
return (
<div className={`overflow-x-auto ${className}`}>
<table className="w-full min-w-[800px]">
{/* Header */}
<thead>
<tr>
<th className="text-left py-4 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 w-64">
{t('marketing.pricing.featureComparison.features', 'Features')}
</th>
{sortedPlans.map((planVersion) => (
<th
key={planVersion.id}
className={`text-center py-4 px-4 text-sm font-semibold border-b border-gray-200 dark:border-gray-700 ${
planVersion.is_most_popular
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20'
: 'text-gray-900 dark:text-white'
}`}
>
{planVersion.plan.name}
</th>
))}
</tr>
</thead>
<tbody>
{FEATURE_CATEGORIES.map((category) => (
<React.Fragment key={category.key}>
{/* Category Header */}
<tr>
<td
colSpan={sortedPlans.length + 1}
className="py-3 px-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50"
>
{t(
`marketing.pricing.featureComparison.categories.${category.key}`,
category.key.charAt(0).toUpperCase() + category.key.slice(1)
)}
</td>
</tr>
{/* Features */}
{category.features.map((feature) => (
<tr
key={feature.code}
className="border-b border-gray-100 dark:border-gray-800"
>
<td className="py-3 px-4 text-sm text-gray-700 dark:text-gray-300">
{t(
`marketing.pricing.featureComparison.features.${feature.code}`,
feature.label
)}
</td>
{sortedPlans.map((planVersion) => (
<td
key={`${planVersion.id}-${feature.code}`}
className={`py-3 px-4 text-center ${
planVersion.is_most_popular
? 'bg-brand-50/50 dark:bg-brand-900/10'
: ''
}`}
>
<FeatureValue
planVersion={planVersion}
featureCode={feature.code}
/>
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
);
};
interface FeatureValueProps {
planVersion: PublicPlanVersion;
featureCode: string;
}
const FeatureValue: React.FC<FeatureValueProps> = ({
planVersion,
featureCode,
}) => {
const value = getPlanFeatureValue(planVersion, featureCode);
// Handle null/undefined - feature not set
if (value === null || value === undefined) {
return (
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
);
}
// Boolean feature
if (typeof value === 'boolean') {
return value ? (
<Check className="w-5 h-5 text-green-500 mx-auto" />
) : (
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
);
}
// Integer feature (limit)
if (typeof value === 'number') {
// Special handling for storage (convert MB to GB if > 1000)
if (featureCode === 'max_storage_mb') {
if (value === 0) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
Unlimited
</span>
);
}
if (value >= 1000) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{(value / 1000).toFixed(0)} GB
</span>
);
}
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{value} MB
</span>
);
}
// Regular limit display
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formatLimit(value)}
</span>
);
}
// Fallback
return <Minus className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />;
};
export default FeatureComparisonTable;