/** * 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 = ({ 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('basics'); const [newMarketingFeature, setNewMarketingFeature] = useState(''); // Form data const [formData, setFormData] = useState(() => ({ // 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>({}); // 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 = {}; 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 = {}; 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 ( {/* Grandfathering Warning */} {hasSubscribers && ( This version has {initialData?.version?.subscriber_count} active subscriber(s). Saving will create a new version (grandfathering). Existing subscribers keep their current plan. } /> )} {/* Step Indicator */}
{steps.map((step, index) => { const isActive = step.id === currentStep; const isCompleted = index < currentStepIndex; const StepIcon = step.icon; return ( {index > 0 && (
)} ); })}
{/* Main Form Area */}
{/* Step 1: Basics */} {currentStep === 'basics' && (
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 && (

{errors.code}

)}
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 && (

{errors.name}

)}