Files
smoothschedule/frontend/src/billing/components/PlanEditorWizard.tsx
poduck b384d9912a 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>
2025-12-12 21:00:54 -05:00

881 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PlanEditorWizard Component
*
* A multi-step wizard for creating or editing subscription plans.
* Replaces the large form in PlanModal with guided step-by-step editing.
*
* Steps:
* 1. Basics - Name, code, description, active status
* 2. Pricing - Monthly/yearly prices, trial days, transaction fees
* 3. Features - Feature picker for capabilities and limits
* 4. Display - Visibility, marketing features, Stripe integration
*/
import React, { useState, useMemo } from 'react';
import {
Package,
DollarSign,
Check,
Star,
Loader2,
ChevronLeft,
} from 'lucide-react';
import { Modal, Alert } from '../../components/ui';
import { FeaturePicker } from './FeaturePicker';
import {
useFeatures,
useAddOnProducts,
useCreatePlan,
useCreatePlanVersion,
useUpdatePlan,
useUpdatePlanVersion,
type PlanFeatureWrite,
} from '../../hooks/useBillingAdmin';
// =============================================================================
// Types
// =============================================================================
export interface PlanEditorWizardProps {
isOpen: boolean;
onClose: () => void;
mode: 'create' | 'edit';
initialData?: {
id?: number;
code?: string;
name?: string;
description?: string;
display_order?: number;
is_active?: boolean;
version?: {
id?: number;
name?: string;
price_monthly_cents?: number;
price_yearly_cents?: number;
transaction_fee_percent?: string | number;
transaction_fee_fixed_cents?: number;
trial_days?: number;
is_public?: boolean;
is_most_popular?: boolean;
show_price?: boolean;
marketing_features?: string[];
stripe_product_id?: string;
stripe_price_id_monthly?: string;
stripe_price_id_yearly?: string;
features?: Array<{
feature: { code: string };
bool_value: boolean | null;
int_value: number | null;
}>;
subscriber_count?: number;
};
};
}
type WizardStep = 'basics' | 'pricing' | 'features' | 'display';
interface WizardFormData {
// Plan fields
code: string;
name: string;
description: string;
display_order: number;
is_active: boolean;
// Version fields
version_name: string;
price_monthly_cents: number;
price_yearly_cents: number;
transaction_fee_percent: number;
transaction_fee_fixed_cents: number;
trial_days: number;
is_public: boolean;
is_most_popular: boolean;
show_price: boolean;
marketing_features: string[];
stripe_product_id: string;
stripe_price_id_monthly: string;
stripe_price_id_yearly: string;
selectedFeatures: PlanFeatureWrite[];
}
// =============================================================================
// Component
// =============================================================================
export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
isOpen,
onClose,
mode,
initialData,
}) => {
const { data: features, isLoading: featuresLoading } = useFeatures();
const { data: addons } = useAddOnProducts();
const createPlanMutation = useCreatePlan();
const createVersionMutation = useCreatePlanVersion();
const updatePlanMutation = useUpdatePlan();
const updateVersionMutation = useUpdatePlanVersion();
const isNewPlan = mode === 'create';
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
const [newMarketingFeature, setNewMarketingFeature] = useState('');
// Form data
const [formData, setFormData] = useState<WizardFormData>(() => ({
// Plan fields
code: initialData?.code || '',
name: initialData?.name || '',
description: initialData?.description || '',
display_order: initialData?.display_order || 0,
is_active: initialData?.is_active ?? true,
// Version fields
version_name: initialData?.version?.name || '',
price_monthly_cents: initialData?.version?.price_monthly_cents || 0,
price_yearly_cents: initialData?.version?.price_yearly_cents || 0,
transaction_fee_percent:
typeof initialData?.version?.transaction_fee_percent === 'string'
? parseFloat(initialData.version.transaction_fee_percent)
: initialData?.version?.transaction_fee_percent || 4.0,
transaction_fee_fixed_cents: initialData?.version?.transaction_fee_fixed_cents || 40,
trial_days: initialData?.version?.trial_days || 14,
is_public: initialData?.version?.is_public ?? true,
is_most_popular: initialData?.version?.is_most_popular || false,
show_price: initialData?.version?.show_price ?? true,
marketing_features: initialData?.version?.marketing_features || [],
stripe_product_id: initialData?.version?.stripe_product_id || '',
stripe_price_id_monthly: initialData?.version?.stripe_price_id_monthly || '',
stripe_price_id_yearly: initialData?.version?.stripe_price_id_yearly || '',
selectedFeatures:
initialData?.version?.features?.map((f) => ({
feature_code: f.feature.code,
bool_value: f.bool_value,
int_value: f.int_value,
})) || [],
}));
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({});
// Wizard steps configuration
const steps: Array<{ id: WizardStep; label: string; icon: React.ElementType }> = [
{ id: 'basics', label: 'Basics', icon: Package },
{ id: 'pricing', label: 'Pricing', icon: DollarSign },
{ id: 'features', label: 'Features', icon: Check },
{ id: 'display', label: 'Display', icon: Star },
];
const currentStepIndex = steps.findIndex((s) => s.id === currentStep);
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
// Validation
const validateBasics = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.code.trim()) newErrors.code = 'Plan code is required';
if (!formData.name.trim()) newErrors.name = 'Plan name is required';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validatePricing = (): boolean => {
const newErrors: Record<string, string> = {};
if (formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100) {
newErrors.transaction_fee_percent = 'Fee must be between 0 and 100';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Navigation
const canProceed = useMemo(() => {
if (currentStep === 'basics') {
return formData.code.trim() !== '' && formData.name.trim() !== '';
}
return true;
}, [currentStep, formData.code, formData.name]);
const goNext = () => {
if (currentStep === 'basics' && !validateBasics()) return;
if (currentStep === 'pricing' && !validatePricing()) return;
if (!isLastStep) {
setCurrentStep(steps[currentStepIndex + 1].id);
}
};
const goPrev = () => {
if (!isFirstStep) {
setCurrentStep(steps[currentStepIndex - 1].id);
}
};
const goToStep = (stepId: WizardStep) => {
// Only allow navigating to visited steps or current step
const targetIndex = steps.findIndex((s) => s.id === stepId);
if (targetIndex <= currentStepIndex || canProceed) {
setCurrentStep(stepId);
}
};
// Form handlers
const updateCode = (value: string) => {
// Sanitize: lowercase, no spaces, only alphanumeric and hyphens/underscores
const sanitized = value.toLowerCase().replace(/[^a-z0-9_-]/g, '');
setFormData((prev) => ({ ...prev, code: sanitized }));
};
const addMarketingFeature = () => {
if (newMarketingFeature.trim()) {
setFormData((prev) => ({
...prev,
marketing_features: [...prev.marketing_features, newMarketingFeature.trim()],
}));
setNewMarketingFeature('');
}
};
const removeMarketingFeature = (index: number) => {
setFormData((prev) => ({
...prev,
marketing_features: prev.marketing_features.filter((_, i) => i !== index),
}));
};
// Submit
const handleSubmit = async () => {
if (!validateBasics() || !validatePricing()) return;
try {
if (isNewPlan) {
// Create Plan first
await createPlanMutation.mutateAsync({
code: formData.code,
name: formData.name,
description: formData.description,
display_order: formData.display_order,
is_active: formData.is_active,
});
// Create first version
await createVersionMutation.mutateAsync({
plan_code: formData.code,
name: formData.version_name || `${formData.name} v1`,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
});
} else if (initialData?.id) {
// Update plan
await updatePlanMutation.mutateAsync({
id: initialData.id,
name: formData.name,
description: formData.description,
display_order: formData.display_order,
is_active: formData.is_active,
});
// Update version if exists
if (initialData?.version?.id) {
await updateVersionMutation.mutateAsync({
id: initialData.version.id,
name: formData.version_name,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
});
}
}
onClose();
} catch (error) {
console.error('Failed to save plan:', error);
}
};
const isLoading =
createPlanMutation.isPending ||
createVersionMutation.isPending ||
updatePlanMutation.isPending ||
updateVersionMutation.isPending;
// Derived values for display
const monthlyEquivalent = formData.price_yearly_cents > 0
? (formData.price_yearly_cents / 12 / 100).toFixed(2)
: null;
const transactionFeeExample = () => {
const percent = formData.transaction_fee_percent / 100;
const fixed = formData.transaction_fee_fixed_cents / 100;
const total = (100 * percent + fixed).toFixed(2);
return `On a $100 transaction: $${total} fee`;
};
// Fee validation warning
const feeError =
formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100
? 'Fee must be between 0 and 100'
: null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isNewPlan ? 'Create New Plan' : `Edit ${initialData?.name || 'Plan'}`}
size="4xl"
>
{/* Grandfathering Warning */}
{hasSubscribers && (
<Alert
variant="warning"
className="mb-4"
message={
<>
This version has <strong>{initialData?.version?.subscriber_count}</strong> active
subscriber(s). Saving will create a new version (grandfathering). Existing subscribers
keep their current plan.
</>
}
/>
)}
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
{steps.map((step, index) => {
const isActive = step.id === currentStep;
const isCompleted = index < currentStepIndex;
const StepIcon = step.icon;
return (
<React.Fragment key={step.id}>
{index > 0 && (
<div
className={`h-px w-8 ${
isCompleted ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}
<button
type="button"
onClick={() => goToStep(step.id)}
aria-label={step.label}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: isCompleted
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
<StepIcon className="w-4 h-4" />
<span className="hidden sm:inline">{step.label}</span>
</button>
</React.Fragment>
);
})}
</div>
<div className="flex gap-6">
{/* Main Form Area */}
<div className="flex-1 max-h-[60vh] overflow-y-auto">
{/* Step 1: Basics */}
{currentStep === 'basics' && (
<div className="space-y-4 p-1">
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="plan-code"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Plan Code *
</label>
<input
id="plan-code"
type="text"
value={formData.code}
onChange={(e) => updateCode(e.target.value)}
required
disabled={!isNewPlan}
placeholder="e.g., starter, pro, enterprise"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50"
/>
{errors.code && (
<p className="text-xs text-red-500 mt-1">{errors.code}</p>
)}
</div>
<div>
<label
htmlFor="plan-name"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Display Name *
</label>
<input
id="plan-name"
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required
placeholder="e.g., Starter, Professional, Enterprise"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{errors.name && (
<p className="text-xs text-red-500 mt-1">{errors.name}</p>
)}
</div>
</div>
<div>
<label
htmlFor="plan-description"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Description
</label>
<textarea
id="plan-description"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
rows={2}
placeholder="Brief description of this plan..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex items-center gap-4 pt-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Active (available for purchase)
</span>
</label>
</div>
</div>
)}
{/* Step 2: Pricing */}
{currentStep === 'pricing' && (
<div className="space-y-6 p-1">
{/* Subscription Pricing */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<DollarSign className="w-4 h-4" /> Subscription Pricing
</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<label
htmlFor="price-monthly"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Monthly Price ($)
</label>
<input
id="price-monthly"
type="number"
step="0.01"
min="0"
value={formData.price_monthly_cents / 100}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price_monthly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label
htmlFor="price-yearly"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Yearly Price ($)
</label>
<input
id="price-yearly"
type="number"
step="0.01"
min="0"
value={formData.price_yearly_cents / 100}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price_yearly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{monthlyEquivalent && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
=${monthlyEquivalent}/mo equivalent
</p>
)}
</div>
<div>
<label
htmlFor="trial-days"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Trial Days
</label>
<input
id="trial-days"
type="number"
min="0"
value={formData.trial_days}
onChange={(e) =>
setFormData((prev) => ({
...prev,
trial_days: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
{/* Transaction Fees */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Transaction Fees
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="fee-percent"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Fee Percentage (%)
</label>
<input
id="fee-percent"
type="number"
step="0.1"
min="0"
max="100"
value={formData.transaction_fee_percent}
onChange={(e) =>
setFormData((prev) => ({
...prev,
transaction_fee_percent: parseFloat(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{feeError && (
<p className="text-xs text-red-500 mt-1">{feeError}</p>
)}
</div>
<div>
<label
htmlFor="fee-fixed"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Fixed Fee (cents)
</label>
<input
id="fee-fixed"
type="number"
min="0"
value={formData.transaction_fee_fixed_cents}
onChange={(e) =>
setFormData((prev) => ({
...prev,
transaction_fee_fixed_cents: parseInt(e.target.value) || 0,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{transactionFeeExample()}
</p>
</div>
</div>
)}
{/* Step 3: Features */}
{currentStep === 'features' && (
<div className="p-1">
{featuresLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
) : (
<FeaturePicker
features={features || []}
selectedFeatures={formData.selectedFeatures}
onChange={(selected) =>
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
}
/>
)}
</div>
)}
{/* Step 4: Display */}
{currentStep === 'display' && (
<div className="space-y-6 p-1">
{/* Visibility */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Visibility Settings
</h4>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_public}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_public: e.target.checked }))
}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Show on pricing page
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_most_popular}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_most_popular: e.target.checked }))
}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
"Most Popular" badge
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.show_price}
onChange={(e) =>
setFormData((prev) => ({ ...prev, show_price: e.target.checked }))
}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Display price</span>
</label>
</div>
</div>
{/* Marketing Features */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Marketing Feature List
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Bullet points shown on pricing page. Separate from actual feature access.
</p>
<div className="space-y-2">
{formData.marketing_features.map((feature, index) => (
<div key={index} className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="flex-1 text-sm text-gray-700 dark:text-gray-300">
{feature}
</span>
<button
type="button"
onClick={() => removeMarketingFeature(index)}
className="text-gray-400 hover:text-red-500 p-1"
>
×
</button>
</div>
))}
<div className="flex gap-2">
<input
type="text"
value={newMarketingFeature}
onChange={(e) => setNewMarketingFeature(e.target.value)}
onKeyPress={(e) =>
e.key === 'Enter' && (e.preventDefault(), addMarketingFeature())
}
placeholder="e.g., Unlimited appointments"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
<button
type="button"
onClick={addMarketingFeature}
className="px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
>
Add
</button>
</div>
</div>
</div>
{/* Stripe */}
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
Stripe Integration
</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Product ID
</label>
<input
type="text"
value={formData.stripe_product_id}
onChange={(e) =>
setFormData((prev) => ({ ...prev, stripe_product_id: e.target.value }))
}
placeholder="prod_..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly Price ID
</label>
<input
type="text"
value={formData.stripe_price_id_monthly}
onChange={(e) =>
setFormData((prev) => ({
...prev,
stripe_price_id_monthly: e.target.value,
}))
}
placeholder="price_..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Yearly Price ID
</label>
<input
type="text"
value={formData.stripe_price_id_yearly}
onChange={(e) =>
setFormData((prev) => ({ ...prev, stripe_price_id_yearly: e.target.value }))
}
placeholder="price_..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
</div>
</div>
</div>
)}
</div>
{/* Live Summary Panel */}
<div className="w-64 border-l border-gray-200 dark:border-gray-700 pl-6 hidden lg:block">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4">Plan Summary</h4>
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Name:</span>
<p className="font-medium text-gray-900 dark:text-white">
{formData.name || '(not set)'}
</p>
</div>
{formData.price_monthly_cents > 0 && (
<div>
<span className="text-gray-500 dark:text-gray-400">Price:</span>
<p className="font-medium text-gray-900 dark:text-white">
${(formData.price_monthly_cents / 100).toFixed(2)}/mo
</p>
</div>
)}
<div>
<span className="text-gray-500 dark:text-gray-400">Features:</span>
<p className="font-medium text-gray-900 dark:text-white">
{formData.selectedFeatures.length} feature
{formData.selectedFeatures.length !== 1 ? 's' : ''}
</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Status:</span>
<p className="font-medium text-gray-900 dark:text-white">
{formData.is_active ? 'Active' : 'Inactive'}
{formData.is_public ? ', Public' : ', Hidden'}
</p>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div>
{!isFirstStep && (
<button
type="button"
onClick={goPrev}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
)}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
{!isLastStep ? (
<button
type="button"
onClick={goNext}
disabled={!canProceed}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
</button>
)}
</div>
</div>
</Modal>
);
};