- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10) - Gate site builder access based on max_public_pages entitlement - Auto-create Site with default booking page for new tenants - Update PageEditor to use useEntitlements hook for permission checks - Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor - Add force update functionality for superusers in PlanEditorWizard - Add comprehensive filters to all safe scripting get_* methods - Update plugin documentation with full filter reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
993 lines
39 KiB
TypeScript
993 lines
39 KiB
TypeScript
/**
|
||
* 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,
|
||
useForceUpdatePlanVersion,
|
||
isForceUpdateConfirmRequired,
|
||
type PlanFeatureWrite,
|
||
} from '../../hooks/useBillingAdmin';
|
||
import { useCurrentUser } from '../../hooks/useAuth';
|
||
|
||
// =============================================================================
|
||
// 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 { data: currentUser } = useCurrentUser();
|
||
const createPlanMutation = useCreatePlan();
|
||
const createVersionMutation = useCreatePlanVersion();
|
||
const updatePlanMutation = useUpdatePlan();
|
||
const updateVersionMutation = useUpdatePlanVersion();
|
||
const forceUpdateMutation = useForceUpdatePlanVersion();
|
||
|
||
const isNewPlan = mode === 'create';
|
||
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
|
||
const isSuperuser = currentUser?.role === 'superuser';
|
||
|
||
// Force update state (for updating without creating new version)
|
||
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
|
||
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Force update handler (updates existing version without creating new one)
|
||
const handleForceUpdate = async () => {
|
||
if (!initialData?.version?.id) return;
|
||
|
||
try {
|
||
setForceUpdateError(null);
|
||
|
||
// First call without confirm to get affected subscriber count
|
||
const response = await forceUpdateMutation.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,
|
||
confirm: true, // Confirm immediately since user already acknowledged
|
||
});
|
||
|
||
// If successful, close the modal
|
||
if (!isForceUpdateConfirmRequired(response)) {
|
||
onClose();
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to force update plan:', error);
|
||
setForceUpdateError('Failed to update plan. Please try again.');
|
||
}
|
||
};
|
||
|
||
const isLoading =
|
||
createPlanMutation.isPending ||
|
||
createVersionMutation.isPending ||
|
||
updatePlanMutation.isPending ||
|
||
updateVersionMutation.isPending ||
|
||
forceUpdateMutation.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 && !showForceUpdateConfirm && (
|
||
<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.
|
||
</>
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{/* Force Update Confirmation Dialog */}
|
||
{showForceUpdateConfirm && (
|
||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||
<div className="flex items-start gap-3">
|
||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||
<div className="flex-1">
|
||
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
|
||
Warning: This will affect existing customers
|
||
</h4>
|
||
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
|
||
You are about to update this plan version <strong>in place</strong>. This will immediately
|
||
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
|
||
subscriber(s). This action cannot be undone.
|
||
</p>
|
||
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
|
||
Only use this for correcting errors or minor adjustments. For significant changes,
|
||
use the standard save which creates a new version and grandfathers existing subscribers.
|
||
</p>
|
||
{forceUpdateError && (
|
||
<Alert variant="error" message={forceUpdateError} className="mb-3" />
|
||
)}
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowForceUpdateConfirm(false);
|
||
setForceUpdateError(null);
|
||
}}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleForceUpdate}
|
||
disabled={isLoading}
|
||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||
Yes, Update All Subscribers
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 && !showForceUpdateConfirm && (
|
||
<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>
|
||
{!showForceUpdateConfirm && (
|
||
<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>
|
||
) : (
|
||
<>
|
||
{/* Force Update button - only for superusers editing plans with subscribers */}
|
||
{hasSubscribers && isSuperuser && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowForceUpdateConfirm(true)}
|
||
disabled={isLoading || !canProceed}
|
||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<AlertTriangle className="w-4 h-4" />
|
||
Update Without Versioning
|
||
</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>
|
||
);
|
||
};
|