Add stackable add-ons with compounding integer features

- Add is_stackable field to AddOnProduct model for add-ons that can be
  purchased multiple times
- Add quantity field to SubscriptionAddOn for tracking purchase count
- Update EntitlementService to ADD integer add-on values to base plan
  (instead of max) and multiply by quantity for stackable add-ons
- Add feature selection to AddOnEditorModal using FeaturePicker component
- Add AddOnFeatureSerializer for nested feature CRUD on add-ons
- Fix Create Add-on button styling to use solid blue (was muted outline)
- Widen billing sidebar from 320px to 384px to prevent text wrapping

🤖 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 03:10:53 -05:00
parent 6afa3d7415
commit a8c271b5e3
18 changed files with 4715 additions and 36 deletions

View File

@@ -0,0 +1,881 @@
/**
* 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,
AlertTriangle,
} 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>
);
};