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

@@ -132,6 +132,8 @@ describe('Billing API', () => {
code: 'sms_pack',
name: 'SMS Pack',
price_monthly_cents: 500,
price_one_time_cents: 0,
is_stackable: false,
is_active: true,
},
];

View File

@@ -75,6 +75,10 @@ export interface AddOnProduct {
name: string;
description?: string;
price_monthly_cents: number;
price_one_time_cents: number;
stripe_product_id?: string;
stripe_price_id?: string;
is_stackable: boolean;
is_active: boolean;
}

View File

@@ -0,0 +1,376 @@
/**
* AddOnEditorModal Component
*
* Modal for creating or editing add-on products with feature selection.
*/
import React, { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { Modal, FormInput, Alert } from '../../components/ui';
import { FeaturePicker } from './FeaturePicker';
import {
useFeatures,
useCreateAddOnProduct,
useUpdateAddOnProduct,
type AddOnProduct,
type AddOnFeatureWrite,
} from '../../hooks/useBillingAdmin';
// =============================================================================
// Types
// =============================================================================
export interface AddOnEditorModalProps {
isOpen: boolean;
onClose: () => void;
addon?: AddOnProduct | null;
}
interface FormData {
code: string;
name: string;
description: string;
price_monthly_cents: number;
price_one_time_cents: number;
stripe_product_id: string;
stripe_price_id: string;
is_stackable: boolean;
is_active: boolean;
selectedFeatures: AddOnFeatureWrite[];
}
// =============================================================================
// Component
// =============================================================================
export const AddOnEditorModal: React.FC<AddOnEditorModalProps> = ({
isOpen,
onClose,
addon,
}) => {
const isEditMode = !!addon;
const [formData, setFormData] = useState<FormData>({
code: '',
name: '',
description: '',
price_monthly_cents: 0,
price_one_time_cents: 0,
stripe_product_id: '',
stripe_price_id: '',
is_stackable: false,
is_active: true,
selectedFeatures: [],
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
// Fetch features
const { data: features, isLoading: featuresLoading } = useFeatures();
const createMutation = useCreateAddOnProduct();
const updateMutation = useUpdateAddOnProduct();
// Initialize form when addon changes
useEffect(() => {
if (addon) {
setFormData({
code: addon.code,
name: addon.name,
description: addon.description || '',
price_monthly_cents: addon.price_monthly_cents,
price_one_time_cents: addon.price_one_time_cents,
stripe_product_id: addon.stripe_product_id || '',
stripe_price_id: addon.stripe_price_id || '',
is_stackable: addon.is_stackable,
is_active: addon.is_active,
selectedFeatures:
addon.features?.map((af) => ({
feature_code: af.feature.code,
bool_value: af.bool_value,
int_value: af.int_value,
})) || [],
});
} else {
setFormData({
code: '',
name: '',
description: '',
price_monthly_cents: 0,
price_one_time_cents: 0,
stripe_product_id: '',
stripe_price_id: '',
is_stackable: false,
is_active: true,
selectedFeatures: [],
});
}
setErrors({});
}, [addon, isOpen]);
const validate = (): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
if (!formData.code.trim()) {
newErrors.code = 'Code is required';
} else if (!/^[a-z0-9_]+$/.test(formData.code)) {
newErrors.code = 'Code must be lowercase letters, numbers, and underscores only';
}
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (formData.price_monthly_cents < 0) {
newErrors.price_monthly_cents = 'Price cannot be negative';
}
if (formData.price_one_time_cents < 0) {
newErrors.price_one_time_cents = 'Price cannot be negative';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
const payload = {
code: formData.code,
name: formData.name,
description: formData.description,
price_monthly_cents: formData.price_monthly_cents,
price_one_time_cents: formData.price_one_time_cents,
stripe_product_id: formData.stripe_product_id,
stripe_price_id: formData.stripe_price_id,
is_stackable: formData.is_stackable,
is_active: formData.is_active,
features: formData.selectedFeatures,
};
try {
if (isEditMode && addon) {
await updateMutation.mutateAsync({
id: addon.id,
...payload,
});
} else {
await createMutation.mutateAsync(payload);
}
onClose();
} catch (error) {
console.error('Failed to save add-on:', error);
}
};
const handleChange = (field: keyof FormData, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditMode ? `Edit ${addon?.name}` : 'Create Add-On'}
size="4xl"
>
<div className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Basic Information</h4>
<div className="grid grid-cols-2 gap-4">
<FormInput
label="Code"
value={formData.code}
onChange={(e) => handleChange('code', e.target.value)}
error={errors.code}
placeholder="sms_credits_pack"
disabled={isEditMode}
hint={isEditMode ? 'Code cannot be changed' : 'Unique identifier (lowercase, underscores)'}
/>
<FormInput
label="Name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
error={errors.name}
placeholder="SMS Credits Pack"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="Description of the add-on..."
rows={2}
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 className="flex items-center gap-6">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
<label htmlFor="is_active" className="text-sm text-gray-700 dark:text-gray-300">
Active (available for purchase)
</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_stackable"
checked={formData.is_stackable}
onChange={(e) => handleChange('is_stackable', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
<label htmlFor="is_stackable" className="text-sm text-gray-700 dark:text-gray-300">
Stackable (can purchase multiple, values compound)
</label>
</div>
</div>
</div>
{/* Pricing */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Pricing</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly Price
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
min="0"
step="0.01"
value={(formData.price_monthly_cents / 100).toFixed(2)}
onChange={(e) =>
handleChange('price_monthly_cents', Math.round(parseFloat(e.target.value || '0') * 100))
}
className="w-full pl-7 pr-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>
{errors.price_monthly_cents && (
<p className="mt-1 text-sm text-red-600">{errors.price_monthly_cents}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
One-Time Price
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
min="0"
step="0.01"
value={(formData.price_one_time_cents / 100).toFixed(2)}
onChange={(e) =>
handleChange('price_one_time_cents', Math.round(parseFloat(e.target.value || '0') * 100))
}
className="w-full pl-7 pr-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>
{errors.price_one_time_cents && (
<p className="mt-1 text-sm text-red-600">{errors.price_one_time_cents}</p>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
For one-time purchases (credits, etc.)
</p>
</div>
</div>
</div>
{/* Features */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
Features Granted by This Add-On
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the features that subscribers will receive when they purchase this add-on.
</p>
{featuresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
</div>
) : (
<FeaturePicker
features={features || []}
selectedFeatures={formData.selectedFeatures}
onChange={(selected) =>
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
}
/>
)}
</div>
{/* Stripe Integration */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Stripe Integration</h4>
<div className="grid grid-cols-2 gap-4">
<FormInput
label="Stripe Product ID"
value={formData.stripe_product_id}
onChange={(e) => handleChange('stripe_product_id', e.target.value)}
placeholder="prod_..."
/>
<FormInput
label="Stripe Price ID"
value={formData.stripe_price_id}
onChange={(e) => handleChange('stripe_price_id', e.target.value)}
placeholder="price_..."
/>
</div>
{!formData.stripe_product_id && (
<Alert
variant="info"
message="Configure Stripe IDs to enable purchasing. Create the product in Stripe Dashboard first."
/>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<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>
<button
type="button"
onClick={handleSubmit}
disabled={isPending}
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 flex items-center gap-2"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{isEditMode ? 'Save Changes' : 'Create Add-On'}
</button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,337 @@
/**
* CatalogListPanel Component
*
* Left sidebar panel displaying a searchable, filterable list of plans and add-ons.
* Supports filtering by type, status, visibility, and legacy status.
*/
import React, { useState, useMemo } from 'react';
import { Search, Plus, Package, Puzzle, Eye, EyeOff, Archive } from 'lucide-react';
import { Badge } from '../../components/ui';
// =============================================================================
// Types
// =============================================================================
export interface CatalogItem {
id: number;
type: 'plan' | 'addon';
code: string;
name: string;
isActive: boolean;
isPublic: boolean;
isLegacy: boolean;
priceMonthly?: number;
priceYearly?: number;
subscriberCount?: number;
stripeProductId?: string;
}
export interface CatalogListPanelProps {
items: CatalogItem[];
selectedId: number | null;
onSelect: (item: CatalogItem) => void;
onCreatePlan: () => void;
onCreateAddon: () => void;
isLoading?: boolean;
}
type TypeFilter = 'all' | 'plan' | 'addon';
type StatusFilter = 'all' | 'active' | 'inactive';
type VisibilityFilter = 'all' | 'public' | 'hidden';
type LegacyFilter = 'all' | 'current' | 'legacy';
// =============================================================================
// Component
// =============================================================================
export const CatalogListPanel: React.FC<CatalogListPanelProps> = ({
items,
selectedId,
onSelect,
onCreatePlan,
onCreateAddon,
isLoading = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>('all');
const [legacyFilter, setLegacyFilter] = useState<LegacyFilter>('all');
// Filter items
const filteredItems = useMemo(() => {
return items.filter((item) => {
// Type filter
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
// Status filter
if (statusFilter === 'active' && !item.isActive) return false;
if (statusFilter === 'inactive' && item.isActive) return false;
// Visibility filter
if (visibilityFilter === 'public' && !item.isPublic) return false;
if (visibilityFilter === 'hidden' && item.isPublic) return false;
// Legacy filter
if (legacyFilter === 'current' && item.isLegacy) return false;
if (legacyFilter === 'legacy' && !item.isLegacy) return false;
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
item.name.toLowerCase().includes(term) ||
item.code.toLowerCase().includes(term)
);
}
return true;
});
}, [items, typeFilter, statusFilter, visibilityFilter, legacyFilter, searchTerm]);
const formatPrice = (cents?: number): string => {
if (cents === undefined || cents === 0) return 'Free';
return `$${(cents / 100).toFixed(2)}`;
};
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
{/* Header with Create buttons */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<button
onClick={onCreatePlan}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Plan
</button>
<button
onClick={onCreateAddon}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Create Add-on
</button>
</div>
</div>
{/* Search */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by name or code..."
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
{/* Filters */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="type-filter" className="sr-only">
Type
</label>
<select
id="type-filter"
aria-label="Type"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as TypeFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Types</option>
<option value="plan">Base Plans</option>
<option value="addon">Add-ons</option>
</select>
</div>
<div>
<label htmlFor="status-filter" className="sr-only">
Status
</label>
<select
id="status-filter"
aria-label="Status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="visibility-filter" className="sr-only">
Visibility
</label>
<select
id="visibility-filter"
aria-label="Visibility"
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value as VisibilityFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="hidden">Hidden</option>
</select>
</div>
<div>
<label htmlFor="legacy-filter" className="sr-only">
Legacy
</label>
<select
id="legacy-filter"
aria-label="Legacy"
value={legacyFilter}
onChange={(e) => setLegacyFilter(e.target.value as LegacyFilter)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Versions</option>
<option value="current">Current</option>
<option value="legacy">Legacy</option>
</select>
</div>
</div>
</div>
{/* Items List */}
<div className="flex-1 overflow-y-auto">
{filteredItems.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<Package className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No items found</p>
{searchTerm && (
<p className="text-xs mt-1">Try adjusting your search or filters</p>
)}
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredItems.map((item) => (
<CatalogListItem
key={`${item.type}-${item.id}`}
item={item}
isSelected={selectedId === item.id}
onSelect={() => onSelect(item)}
formatPrice={formatPrice}
/>
))}
</div>
)}
</div>
</div>
);
};
// =============================================================================
// List Item Component
// =============================================================================
interface CatalogListItemProps {
item: CatalogItem;
isSelected: boolean;
onSelect: () => void;
formatPrice: (cents?: number) => string;
}
const CatalogListItem: React.FC<CatalogListItemProps> = ({
item,
isSelected,
onSelect,
formatPrice,
}) => {
return (
<button
onClick={onSelect}
className={`w-full p-4 text-left transition-colors ${
isSelected
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div
className={`p-2 rounded-lg ${
item.type === 'plan'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
}`}
>
{item.type === 'plan' ? (
<Package className="w-4 h-4" />
) : (
<Puzzle className="w-4 h-4" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-white truncate">
{item.name}
</span>
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{item.code}
</span>
</div>
{/* Badges */}
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{/* Type badge */}
<span
className={`px-1.5 py-0.5 text-xs font-medium rounded ${
item.type === 'plan'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400'
}`}
>
{item.type === 'plan' ? 'Base' : 'Add-on'}
</span>
{/* Status badge */}
{!item.isActive && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
Inactive
</span>
)}
{/* Visibility badge */}
{!item.isPublic && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded flex items-center gap-1">
<EyeOff className="w-3 h-3" />
Hidden
</span>
)}
{/* Legacy badge */}
{item.isLegacy && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded flex items-center gap-1">
<Archive className="w-3 h-3" />
Legacy
</span>
)}
</div>
{/* Price and subscriber count */}
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">
{formatPrice(item.priceMonthly)}/mo
</span>
{item.subscriberCount !== undefined && (
<span>{item.subscriberCount} subscribers</span>
)}
</div>
</div>
</div>
</button>
);
};

View File

@@ -0,0 +1,271 @@
/**
* FeaturePicker Component
*
* A searchable picker for selecting features to include in a plan or version.
* Features are grouped by type (boolean capabilities vs integer limits).
* Non-canonical features (not in the catalog) are flagged with a warning.
*/
import React, { useState, useMemo } from 'react';
import { Check, Sliders, Search, X, AlertTriangle } from 'lucide-react';
import { isCanonicalFeature } from '../featureCatalog';
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
export interface FeaturePickerProps {
/** Available features from the API */
features: Feature[];
/** Currently selected features with their values */
selectedFeatures: PlanFeatureWrite[];
/** Callback when selection changes */
onChange: (features: PlanFeatureWrite[]) => void;
/** Optional: Show compact view */
compact?: boolean;
}
export const FeaturePicker: React.FC<FeaturePickerProps> = ({
features,
selectedFeatures,
onChange,
compact = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
// Group features by type
const { booleanFeatures, integerFeatures } = useMemo(() => {
const boolean = features.filter((f) => f.feature_type === 'boolean');
const integer = features.filter((f) => f.feature_type === 'integer');
return { booleanFeatures: boolean, integerFeatures: integer };
}, [features]);
// Filter by search term
const filteredBooleanFeatures = useMemo(() => {
if (!searchTerm) return booleanFeatures;
const term = searchTerm.toLowerCase();
return booleanFeatures.filter(
(f) =>
f.name.toLowerCase().includes(term) ||
f.code.toLowerCase().includes(term) ||
f.description?.toLowerCase().includes(term)
);
}, [booleanFeatures, searchTerm]);
const filteredIntegerFeatures = useMemo(() => {
if (!searchTerm) return integerFeatures;
const term = searchTerm.toLowerCase();
return integerFeatures.filter(
(f) =>
f.name.toLowerCase().includes(term) ||
f.code.toLowerCase().includes(term) ||
f.description?.toLowerCase().includes(term)
);
}, [integerFeatures, searchTerm]);
const hasNoResults =
searchTerm && filteredBooleanFeatures.length === 0 && filteredIntegerFeatures.length === 0;
// Check if a feature is selected
const isSelected = (code: string): boolean => {
return selectedFeatures.some((f) => f.feature_code === code);
};
// Get selected feature data
const getSelectedFeature = (code: string): PlanFeatureWrite | undefined => {
return selectedFeatures.find((f) => f.feature_code === code);
};
// Toggle boolean feature selection
const toggleBooleanFeature = (code: string) => {
if (isSelected(code)) {
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
} else {
onChange([
...selectedFeatures,
{ feature_code: code, bool_value: true, int_value: null },
]);
}
};
// Toggle integer feature selection
const toggleIntegerFeature = (code: string) => {
if (isSelected(code)) {
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
} else {
onChange([
...selectedFeatures,
{ feature_code: code, bool_value: null, int_value: 0 },
]);
}
};
// Update integer feature value
const updateIntegerValue = (code: string, value: number) => {
onChange(
selectedFeatures.map((f) =>
f.feature_code === code ? { ...f, int_value: value } : f
)
);
};
const clearSearch = () => {
setSearchTerm('');
};
return (
<div className="space-y-6">
{/* Search Box */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search features..."
className="w-full pl-10 pr-10 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"
/>
{searchTerm && (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* No Results Message */}
{hasNoResults && (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No features found matching "{searchTerm}"</p>
</div>
)}
{/* Boolean Features (Capabilities) */}
{filteredBooleanFeatures.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Check className="w-4 h-4" /> Capabilities
</h4>
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
{filteredBooleanFeatures.map((feature) => {
const selected = isSelected(feature.code);
const isCanonical = isCanonicalFeature(feature.code);
return (
<label
key={feature.id}
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
selected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleBooleanFeature(feature.code)}
aria-label={feature.name}
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{feature.name}
</span>
{!isCanonical && (
<span title="Not in canonical catalog">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
</span>
)}
</div>
{feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{feature.description}
</span>
)}
</div>
</label>
);
})}
</div>
</div>
)}
{/* Integer Features (Limits & Quotas) */}
{filteredIntegerFeatures.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<Sliders className="w-4 h-4" /> Limits & Quotas
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Set to 0 for unlimited. Uncheck to exclude from plan.
</p>
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
{filteredIntegerFeatures.map((feature) => {
const selectedFeature = getSelectedFeature(feature.code);
const selected = !!selectedFeature;
const isCanonical = isCanonicalFeature(feature.code);
return (
<div
key={feature.id}
className={`flex items-center gap-3 p-3 border rounded-lg ${
selected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
<input
type="checkbox"
checked={selected}
onChange={() => toggleIntegerFeature(feature.code)}
aria-label={feature.name}
className="rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
{feature.name}
</span>
{!isCanonical && (
<span title="Not in canonical catalog">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
</span>
)}
</div>
</div>
</label>
{selected && (
<input
type="number"
min="0"
value={selectedFeature?.int_value || 0}
onChange={(e) =>
updateIntegerValue(feature.code, parseInt(e.target.value) || 0)
}
aria-label={`${feature.name} limit value`}
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="0"
/>
)}
</div>
);
})}
</div>
</div>
)}
{/* Empty State */}
{!searchTerm && features.length === 0 && (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<Sliders className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No features defined yet.</p>
<p className="text-xs mt-1">Add features in the Features Library tab first.</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,609 @@
/**
* PlanDetailPanel Component
*
* Detail view for a selected plan or add-on, shown in the main panel
* of the master-detail layout.
*/
import React, { useState } from 'react';
import {
Package,
Pencil,
Copy,
Trash2,
DollarSign,
Users,
Check,
AlertTriangle,
ExternalLink,
ChevronDown,
ChevronRight,
Plus,
Archive,
} from 'lucide-react';
import { Badge, Alert, Modal, ModalFooter } from '../../components/ui';
import {
usePlanVersionSubscribers,
useDeletePlan,
useDeletePlanVersion,
useMarkVersionLegacy,
formatCentsToDollars,
type PlanWithVersions,
type PlanVersion,
type AddOnProduct,
} from '../../hooks/useBillingAdmin';
// =============================================================================
// Types
// =============================================================================
interface PlanDetailPanelProps {
plan: PlanWithVersions | null;
addon: AddOnProduct | null;
onEdit: () => void;
onDuplicate: () => void;
onCreateVersion: () => void;
onEditVersion: (version: PlanVersion) => void;
}
// =============================================================================
// Component
// =============================================================================
export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
plan,
addon,
onEdit,
onDuplicate,
onCreateVersion,
onEditVersion,
}) => {
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['overview', 'pricing', 'features'])
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const deletePlanMutation = useDeletePlan();
const deleteVersionMutation = useDeletePlanVersion();
const markLegacyMutation = useMarkVersionLegacy();
if (!plan && !addon) {
return (
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
<div className="text-center">
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Select a plan or add-on from the catalog</p>
</div>
</div>
);
}
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const activeVersion = plan?.active_version;
const handleDelete = async () => {
if (!plan) return;
const expectedText = `DELETE ${plan.code}`;
if (deleteConfirmText !== expectedText) {
return;
}
try {
await deletePlanMutation.mutateAsync(plan.id);
setShowDeleteConfirm(false);
setDeleteConfirmText('');
} catch (error) {
console.error('Failed to delete plan:', error);
}
};
// Render Plan Detail
if (plan) {
return (
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-6 z-10">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{plan.name}
</h2>
<span className="px-2 py-1 text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{plan.code}
</span>
{!plan.is_active && <Badge variant="warning">Inactive</Badge>}
</div>
{plan.description && (
<p className="text-gray-600 dark:text-gray-400">{plan.description}</p>
)}
{activeVersion && (
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<DollarSign className="w-4 h-4" />
{activeVersion.price_monthly_cents === 0
? 'Free'
: `$${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo`}
</span>
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{plan.total_subscribers} subscriber{plan.total_subscribers !== 1 ? 's' : ''}
</span>
<span>Version {activeVersion.version}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="inline-flex items-center gap-2 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"
>
<Pencil className="w-4 h-4" />
Edit
</button>
<button
onClick={onDuplicate}
className="inline-flex items-center gap-2 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"
>
<Copy className="w-4 h-4" />
Duplicate
</button>
<button
onClick={onCreateVersion}
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"
>
<Plus className="w-4 h-4" />
New Version
</button>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Overview Section */}
<CollapsibleSection
title="Overview"
isExpanded={expandedSections.has('overview')}
onToggle={() => toggleSection('overview')}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Plan Code</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.code}</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Display Order</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.display_order}</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Status</label>
<p className="font-medium text-gray-900 dark:text-white">
{plan.is_active ? 'Active' : 'Inactive'}
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Total Subscribers</label>
<p className="font-medium text-gray-900 dark:text-white">{plan.total_subscribers}</p>
</div>
</div>
</CollapsibleSection>
{/* Pricing Section */}
{activeVersion && (
<CollapsibleSection
title="Pricing"
isExpanded={expandedSections.has('pricing')}
onToggle={() => toggleSection('pricing')}
>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(activeVersion.price_monthly_cents)}
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Yearly</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(activeVersion.price_yearly_cents)}
</p>
{activeVersion.price_yearly_cents > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
${(activeVersion.price_yearly_cents / 12 / 100).toFixed(2)}/mo
</p>
)}
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Trial</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{activeVersion.trial_days} days
</p>
</div>
</div>
</CollapsibleSection>
)}
{/* Transaction Fees Section */}
{activeVersion && (
<CollapsibleSection
title="Transaction Fees"
isExpanded={expandedSections.has('fees')}
onToggle={() => toggleSection('fees')}
>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Percentage</label>
<p className="font-medium text-gray-900 dark:text-white">
{activeVersion.transaction_fee_percent}%
</p>
</div>
<div>
<label className="text-sm text-gray-500 dark:text-gray-400">Fixed Fee</label>
<p className="font-medium text-gray-900 dark:text-white">
${(activeVersion.transaction_fee_fixed_cents / 100).toFixed(2)}
</p>
</div>
</div>
</CollapsibleSection>
)}
{/* Features Section */}
{activeVersion && activeVersion.features.length > 0 && (
<CollapsibleSection
title={`Features (${activeVersion.features.length})`}
isExpanded={expandedSections.has('features')}
onToggle={() => toggleSection('features')}
>
<div className="grid grid-cols-2 gap-2">
{activeVersion.features.map((f) => (
<div
key={f.id}
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded"
>
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="text-sm text-gray-900 dark:text-white">
{f.feature.name}
</span>
{f.int_value !== null && (
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
{f.int_value === 0 ? 'Unlimited' : f.int_value}
</span>
)}
</div>
))}
</div>
</CollapsibleSection>
)}
{/* Stripe Section */}
{activeVersion &&
(activeVersion.stripe_product_id ||
activeVersion.stripe_price_id_monthly ||
activeVersion.stripe_price_id_yearly) && (
<CollapsibleSection
title="Stripe Integration"
isExpanded={expandedSections.has('stripe')}
onToggle={() => toggleSection('stripe')}
>
<div className="space-y-2">
{activeVersion.stripe_product_id && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Product:</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_product_id}
</code>
</div>
)}
{activeVersion.stripe_price_id_monthly && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
Monthly Price:
</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_price_id_monthly}
</code>
</div>
)}
{activeVersion.stripe_price_id_yearly && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
Yearly Price:
</span>
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{activeVersion.stripe_price_id_yearly}
</code>
</div>
)}
{!activeVersion.stripe_product_id && (
<Alert
variant="warning"
message="No Stripe Product ID configured. This plan cannot be purchased until Stripe is set up."
/>
)}
</div>
</CollapsibleSection>
)}
{/* Versions Section */}
<CollapsibleSection
title={`Versions (${plan.versions.length})`}
isExpanded={expandedSections.has('versions')}
onToggle={() => toggleSection('versions')}
>
<div className="space-y-2">
{plan.versions.map((version) => (
<VersionRow
key={version.id}
version={version}
isActive={!version.is_legacy}
onEdit={() => onEditVersion(version)}
onMarkLegacy={() => markLegacyMutation.mutate(version.id)}
onDelete={() => deleteVersionMutation.mutate(version.id)}
/>
))}
</div>
</CollapsibleSection>
{/* Danger Zone */}
<CollapsibleSection
title="Danger Zone"
isExpanded={expandedSections.has('danger')}
onToggle={() => toggleSection('danger')}
variant="danger"
>
<div className="p-4 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Deleting a plan is permanent and cannot be undone. Plans with active subscribers
cannot be deleted.
</p>
{plan.total_subscribers > 0 ? (
<Alert
variant="warning"
message={`This plan has ${plan.total_subscribers} active subscriber(s) and cannot be deleted.`}
/>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
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"
>
<Trash2 className="w-4 h-4" />
Delete Plan
</button>
)}
</div>
</CollapsibleSection>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<Modal
isOpen
onClose={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
title="Delete Plan"
size="sm"
>
<div className="space-y-4">
<Alert
variant="error"
message="This action cannot be undone. This will permanently delete the plan and all its versions."
/>
<p className="text-sm text-gray-600 dark:text-gray-400">
To confirm, type <strong>DELETE {plan.code}</strong> below:
</p>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
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"
placeholder={`DELETE ${plan.code}`}
/>
</div>
<ModalFooter
onCancel={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
submitText="Delete Plan"
submitVariant="danger"
isDisabled={deleteConfirmText !== `DELETE ${plan.code}`}
isLoading={deletePlanMutation.isPending}
onSubmit={handleDelete}
/>
</Modal>
)}
</div>
);
}
// Render Add-on Detail
if (addon) {
return (
<div className="flex-1 overflow-y-auto">
<div className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{addon.name}
</h2>
<span className="px-2 py-1 text-sm font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded">
{addon.code}
</span>
{!addon.is_active && <Badge variant="warning">Inactive</Badge>}
</div>
{addon.description && (
<p className="text-gray-600 dark:text-gray-400">{addon.description}</p>
)}
</div>
<button
onClick={onEdit}
className="inline-flex items-center gap-2 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"
>
<Pencil className="w-4 h-4" />
Edit
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly Price</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(addon.price_monthly_cents)}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-sm text-gray-500 dark:text-gray-400">One-time Price</label>
<p className="text-xl font-bold text-gray-900 dark:text-white">
${formatCentsToDollars(addon.price_one_time_cents)}
</p>
</div>
</div>
</div>
</div>
);
}
return null;
};
// =============================================================================
// Collapsible Section
// =============================================================================
interface CollapsibleSectionProps {
title: string;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
variant?: 'default' | 'danger';
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
isExpanded,
onToggle,
children,
variant = 'default',
}) => {
return (
<div
className={`border rounded-lg ${
variant === 'danger'
? 'border-red-200 dark:border-red-800'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<button
onClick={onToggle}
className={`w-full flex items-center justify-between p-4 text-left ${
variant === 'danger'
? 'text-red-700 dark:text-red-400'
: 'text-gray-900 dark:text-white'
}`}
>
<span className="font-medium">{title}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
{children}
</div>
)}
</div>
);
};
// =============================================================================
// Version Row
// =============================================================================
interface VersionRowProps {
version: PlanVersion;
isActive: boolean;
onEdit: () => void;
onMarkLegacy: () => void;
onDelete: () => void;
}
const VersionRow: React.FC<VersionRowProps> = ({
version,
isActive,
onEdit,
onMarkLegacy,
onDelete,
}) => {
return (
<div
className={`flex items-center justify-between p-3 rounded-lg ${
version.is_legacy
? 'bg-gray-50 dark:bg-gray-700/50'
: 'bg-blue-50 dark:bg-blue-900/20'
}`}
>
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900 dark:text-white">v{version.version}</span>
<span className="text-gray-500 dark:text-gray-400">{version.name}</span>
{version.is_legacy && (
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded">
Legacy
</span>
)}
{!version.is_public && !version.is_legacy && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
Hidden
</span>
)}
<span className="text-sm text-gray-500 dark:text-gray-400">
{version.subscriber_count} subscriber{version.subscriber_count !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Edit version"
>
<Pencil className="w-4 h-4" />
</button>
{!version.is_legacy && version.subscriber_count === 0 && (
<button
onClick={onMarkLegacy}
className="p-1 text-gray-400 hover:text-amber-600 dark:hover:text-amber-400"
title="Mark as legacy"
>
<Archive className="w-4 h-4" />
</button>
)}
{version.subscriber_count === 0 && (
<button
onClick={onDelete}
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
title="Delete version"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};

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>
);
};

View File

@@ -0,0 +1,477 @@
/**
* Tests for CatalogListPanel Component
*
* TDD: Tests for the sidebar catalog list with search and filtering.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CatalogListPanel, type CatalogItem } from '../CatalogListPanel';
// Sample plan data
const mockPlans: CatalogItem[] = [
{
id: 1,
type: 'plan',
code: 'free',
name: 'Free',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 0,
priceYearly: 0,
subscriberCount: 50,
},
{
id: 2,
type: 'plan',
code: 'starter',
name: 'Starter',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 2900,
priceYearly: 29000,
subscriberCount: 25,
},
{
id: 3,
type: 'plan',
code: 'pro',
name: 'Professional',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 7900,
priceYearly: 79000,
subscriberCount: 10,
},
{
id: 4,
type: 'plan',
code: 'enterprise',
name: 'Enterprise',
isActive: true,
isPublic: false, // Hidden plan
isLegacy: false,
priceMonthly: 19900,
priceYearly: 199000,
subscriberCount: 3,
},
{
id: 5,
type: 'plan',
code: 'legacy_pro',
name: 'Pro (Legacy)',
isActive: false, // Inactive
isPublic: false,
isLegacy: true,
priceMonthly: 4900,
priceYearly: 49000,
subscriberCount: 15,
},
];
// Sample add-on data
const mockAddons: CatalogItem[] = [
{
id: 101,
type: 'addon',
code: 'sms_pack',
name: 'SMS Credits Pack',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 500,
},
{
id: 102,
type: 'addon',
code: 'api_access',
name: 'API Access',
isActive: true,
isPublic: true,
isLegacy: false,
priceMonthly: 2000,
},
{
id: 103,
type: 'addon',
code: 'old_addon',
name: 'Deprecated Add-on',
isActive: false,
isPublic: false,
isLegacy: true,
priceMonthly: 1000,
},
];
const allItems = [...mockPlans, ...mockAddons];
describe('CatalogListPanel', () => {
const defaultProps = {
items: allItems,
selectedId: null,
onSelect: vi.fn(),
onCreatePlan: vi.fn(),
onCreateAddon: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders all items by default', () => {
render(<CatalogListPanel {...defaultProps} />);
// Should show all plans
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
expect(screen.getByText('Professional')).toBeInTheDocument();
expect(screen.getByText('Enterprise')).toBeInTheDocument();
// Should show all addons
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
expect(screen.getByText('API Access')).toBeInTheDocument();
});
it('shows item code as badge', () => {
render(<CatalogListPanel {...defaultProps} />);
expect(screen.getByText('free')).toBeInTheDocument();
expect(screen.getByText('starter')).toBeInTheDocument();
expect(screen.getByText('sms_pack')).toBeInTheDocument();
});
it('shows price for items', () => {
render(<CatalogListPanel {...defaultProps} />);
// Free plan shows "Free/mo" - use getAllByText since "free" appears multiple times
const freeElements = screen.getAllByText(/free/i);
expect(freeElements.length).toBeGreaterThan(0);
// Starter plan shows $29.00/mo
expect(screen.getByText(/\$29/)).toBeInTheDocument();
});
it('shows type badges for plans and add-ons', () => {
render(<CatalogListPanel {...defaultProps} />);
// Should have Base Plan badges for plans
const baseBadges = screen.getAllByText(/base/i);
expect(baseBadges.length).toBeGreaterThan(0);
// Should have Add-on badges
const addonBadges = screen.getAllByText(/add-on/i);
expect(addonBadges.length).toBeGreaterThan(0);
});
it('shows status badges for inactive items', () => {
render(<CatalogListPanel {...defaultProps} />);
// Legacy plan should show inactive badge
const inactiveBadges = screen.getAllByText(/inactive/i);
expect(inactiveBadges.length).toBeGreaterThan(0);
});
it('shows legacy badge for legacy items', () => {
render(<CatalogListPanel {...defaultProps} />);
const legacyBadges = screen.getAllByText(/legacy/i);
expect(legacyBadges.length).toBeGreaterThan(0);
});
it('shows hidden badge for non-public items', () => {
render(<CatalogListPanel {...defaultProps} />);
const hiddenBadges = screen.getAllByText(/hidden/i);
expect(hiddenBadges.length).toBeGreaterThan(0);
});
});
describe('Type Filtering', () => {
it('filters to show only base plans', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// Click the "Base Plans" filter
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'plan');
// Should show plans
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show addons
expect(screen.queryByText('SMS Credits Pack')).not.toBeInTheDocument();
});
it('filters to show only add-ons', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'addon');
// Should show addons
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
expect(screen.getByText('API Access')).toBeInTheDocument();
// Should NOT show plans
expect(screen.queryByText('Free')).not.toBeInTheDocument();
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
});
it('shows all types when "All" is selected', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// First filter to plans only
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'plan');
// Then select "All"
await user.selectOptions(typeFilter, 'all');
// Should show both
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
});
});
describe('Status Filtering', () => {
it('filters to show only active items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const statusFilter = screen.getByRole('combobox', { name: /status/i });
await user.selectOptions(statusFilter, 'active');
// Should show active items
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show inactive items
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
});
it('filters to show only inactive items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const statusFilter = screen.getByRole('combobox', { name: /status/i });
await user.selectOptions(statusFilter, 'inactive');
// Should show inactive items
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
expect(screen.getByText('Deprecated Add-on')).toBeInTheDocument();
// Should NOT show active items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
});
describe('Visibility Filtering', () => {
it('filters to show only public items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
await user.selectOptions(visibilityFilter, 'public');
// Should show public items
expect(screen.getByText('Free')).toBeInTheDocument();
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show hidden items
expect(screen.queryByText('Enterprise')).not.toBeInTheDocument();
});
it('filters to show only hidden items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
await user.selectOptions(visibilityFilter, 'hidden');
// Should show hidden items
expect(screen.getByText('Enterprise')).toBeInTheDocument();
// Should NOT show public items
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
});
});
describe('Legacy Filtering', () => {
it('filters to show only current (non-legacy) items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
await user.selectOptions(legacyFilter, 'current');
// Should show current items
expect(screen.getByText('Free')).toBeInTheDocument();
// Should NOT show legacy items
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
});
it('filters to show only legacy items', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
await user.selectOptions(legacyFilter, 'legacy');
// Should show legacy items
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
// Should NOT show current items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
});
describe('Search Functionality', () => {
it('filters items by name when searching', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'starter');
// Should show Starter plan
expect(screen.getByText('Starter')).toBeInTheDocument();
// Should NOT show other items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
expect(screen.queryByText('Professional')).not.toBeInTheDocument();
});
it('filters items by code when searching', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'sms_pack');
// Should show SMS Credits Pack
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
// Should NOT show other items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
it('shows no results message when search has no matches', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'nonexistent');
expect(screen.getByText(/no items found/i)).toBeInTheDocument();
});
it('search is case-insensitive', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'STARTER');
expect(screen.getByText('Starter')).toBeInTheDocument();
});
});
describe('Selection', () => {
it('calls onSelect when an item is clicked', async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} onSelect={onSelect} />);
const starterItem = screen.getByText('Starter').closest('button');
await user.click(starterItem!);
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
id: 2,
code: 'starter',
}));
});
it('highlights the selected item', () => {
render(<CatalogListPanel {...defaultProps} selectedId={2} />);
// The selected item should have a different style
const starterItem = screen.getByText('Starter').closest('button');
expect(starterItem).toHaveClass('bg-blue-50');
});
});
describe('Create Actions', () => {
it('calls onCreatePlan when "Create Plan" is clicked', async () => {
const onCreatePlan = vi.fn();
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} onCreatePlan={onCreatePlan} />);
const createPlanButton = screen.getByRole('button', { name: /create plan/i });
await user.click(createPlanButton);
expect(onCreatePlan).toHaveBeenCalled();
});
it('calls onCreateAddon when "Create Add-on" is clicked', async () => {
const onCreateAddon = vi.fn();
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} onCreateAddon={onCreateAddon} />);
const createAddonButton = screen.getByRole('button', { name: /create add-on/i });
await user.click(createAddonButton);
expect(onCreateAddon).toHaveBeenCalled();
});
});
describe('Combined Filters', () => {
it('combines type and status filters', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// Filter to inactive plans only
const typeFilter = screen.getByRole('combobox', { name: /type/i });
const statusFilter = screen.getByRole('combobox', { name: /status/i });
await user.selectOptions(typeFilter, 'plan');
await user.selectOptions(statusFilter, 'inactive');
// Should only show inactive plans
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
// Should NOT show active plans or addons
expect(screen.queryByText('Free')).not.toBeInTheDocument();
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
});
it('combines search with filters', async () => {
const user = userEvent.setup();
render(<CatalogListPanel {...defaultProps} />);
// Filter to plans and search for "pro"
const typeFilter = screen.getByRole('combobox', { name: /type/i });
await user.selectOptions(typeFilter, 'plan');
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, 'pro');
// Should show Professional and Pro (Legacy)
expect(screen.getByText('Professional')).toBeInTheDocument();
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
// Should NOT show other items
expect(screen.queryByText('Free')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,327 @@
/**
* Tests for FeaturePicker Component
*
* TDD: These tests define the expected behavior of the FeaturePicker component.
*/
import React, { useState } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FeaturePicker, FeaturePickerProps } from '../FeaturePicker';
import { FEATURE_CATALOG, BOOLEAN_FEATURES, INTEGER_FEATURES } from '../../featureCatalog';
import type { PlanFeatureWrite } from '../../../hooks/useBillingAdmin';
// Mock features from API (similar to what useFeatures() returns)
const mockApiFeatures = [
{ id: 1, code: 'sms_enabled', name: 'SMS Enabled', description: 'Allow SMS', feature_type: 'boolean' as const },
{ id: 2, code: 'email_enabled', name: 'Email Enabled', description: 'Allow email', feature_type: 'boolean' as const },
{ id: 3, code: 'max_users', name: 'Maximum Users', description: 'Max users limit', feature_type: 'integer' as const },
{ id: 4, code: 'max_resources', name: 'Maximum Resources', description: 'Max resources', feature_type: 'integer' as const },
{ id: 5, code: 'custom_feature', name: 'Custom Feature', description: 'Not in catalog', feature_type: 'boolean' as const },
];
/**
* Wrapper component that manages state for controlled FeaturePicker
*/
const StatefulFeaturePicker: React.FC<
Omit<FeaturePickerProps, 'selectedFeatures' | 'onChange'> & {
initialSelectedFeatures?: PlanFeatureWrite[];
onChangeCapture?: (features: PlanFeatureWrite[]) => void;
}
> = ({ initialSelectedFeatures = [], onChangeCapture, ...props }) => {
const [selectedFeatures, setSelectedFeatures] = useState<PlanFeatureWrite[]>(initialSelectedFeatures);
const handleChange = (features: PlanFeatureWrite[]) => {
setSelectedFeatures(features);
onChangeCapture?.(features);
};
return (
<FeaturePicker
{...props}
selectedFeatures={selectedFeatures}
onChange={handleChange}
/>
);
};
describe('FeaturePicker', () => {
const defaultProps = {
features: mockApiFeatures,
selectedFeatures: [],
onChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders boolean features in Capabilities section', () => {
render(<FeaturePicker {...defaultProps} />);
expect(screen.getByText('Capabilities')).toBeInTheDocument();
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
});
it('renders integer features in Limits & Quotas section', () => {
render(<FeaturePicker {...defaultProps} />);
expect(screen.getByText('Limits & Quotas')).toBeInTheDocument();
expect(screen.getByText('Maximum Users')).toBeInTheDocument();
expect(screen.getByText('Maximum Resources')).toBeInTheDocument();
});
it('shows selected features as checked', () => {
const selectedFeatures = [
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
];
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
expect(checkbox).toBeChecked();
});
it('shows integer values for selected integer features', () => {
const selectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 50 },
];
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
const input = screen.getByDisplayValue('50');
expect(input).toBeInTheDocument();
});
});
describe('Feature Selection', () => {
it('calls onChange when a boolean feature is selected', async () => {
const onChange = vi.fn();
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith([
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
]);
});
it('calls onChange when a boolean feature is deselected', async () => {
const selectedFeatures = [
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
];
const onChange = vi.fn();
render(
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
);
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith([]);
});
it('calls onChange when an integer feature is selected with default value 0', async () => {
const onChange = vi.fn();
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
const checkbox = screen.getByRole('checkbox', { name: /maximum users/i });
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith([
{ feature_code: 'max_users', bool_value: null, int_value: 0 },
]);
});
it('calls onChange when integer value is updated', async () => {
const initialSelectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
];
const onChangeCapture = vi.fn();
render(
<StatefulFeaturePicker
features={mockApiFeatures}
initialSelectedFeatures={initialSelectedFeatures}
onChangeCapture={onChangeCapture}
/>
);
const input = screen.getByDisplayValue('10');
await userEvent.clear(input);
await userEvent.type(input, '50');
// Should have been called multiple times as user types
expect(onChangeCapture).toHaveBeenCalled();
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
expect(lastCall).toContainEqual({ feature_code: 'max_users', bool_value: null, int_value: 50 });
});
});
describe('Canonical Catalog Validation', () => {
it('shows warning badge for features not in canonical catalog', () => {
render(<FeaturePicker {...defaultProps} />);
// custom_feature is not in the canonical catalog
const customFeatureRow = screen.getByText('Custom Feature').closest('label');
expect(customFeatureRow).toBeInTheDocument();
// Should show a warning indicator
const warningIndicator = within(customFeatureRow!).queryByTitle(/not in canonical catalog/i);
expect(warningIndicator).toBeInTheDocument();
});
it('does not show warning for canonical features', () => {
render(<FeaturePicker {...defaultProps} />);
// sms_enabled is in the canonical catalog
const smsFeatureRow = screen.getByText('SMS Enabled').closest('label');
expect(smsFeatureRow).toBeInTheDocument();
const warningIndicator = within(smsFeatureRow!).queryByTitle(/not in canonical catalog/i);
expect(warningIndicator).not.toBeInTheDocument();
});
});
describe('Search Functionality', () => {
it('filters features when search term is entered', async () => {
render(<FeaturePicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search features/i);
await userEvent.type(searchInput, 'sms');
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
expect(screen.queryByText('Email Enabled')).not.toBeInTheDocument();
expect(screen.queryByText('Maximum Users')).not.toBeInTheDocument();
});
it('shows no results message when search has no matches', async () => {
render(<FeaturePicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search features/i);
await userEvent.type(searchInput, 'nonexistent');
expect(screen.getByText(/no features found/i)).toBeInTheDocument();
});
it('clears search when clear button is clicked', async () => {
render(<FeaturePicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/search features/i);
await userEvent.type(searchInput, 'sms');
const clearButton = screen.getByRole('button', { name: /clear search/i });
await userEvent.click(clearButton);
expect(searchInput).toHaveValue('');
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
});
});
describe('Payload Shape', () => {
it('produces correct payload shape for boolean features', async () => {
const onChange = vi.fn();
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
await userEvent.click(screen.getByRole('checkbox', { name: /email enabled/i }));
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
// Verify payload shape matches PlanFeatureWrite interface
expect(lastCall).toEqual(
expect.arrayContaining([
expect.objectContaining({
feature_code: expect.any(String),
bool_value: expect.any(Boolean),
int_value: null,
}),
])
);
});
it('produces correct payload shape for integer features', async () => {
const selectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 25 },
];
const onChange = vi.fn();
render(
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
);
const input = screen.getByDisplayValue('25');
await userEvent.clear(input);
await userEvent.type(input, '100');
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(lastCall).toEqual(
expect.arrayContaining([
expect.objectContaining({
feature_code: 'max_users',
bool_value: null,
int_value: expect.any(Number),
}),
])
);
});
it('produces correct payload shape for mixed selection', async () => {
const onChangeCapture = vi.fn();
render(
<StatefulFeaturePicker
features={mockApiFeatures}
onChangeCapture={onChangeCapture}
/>
);
// Select a boolean feature
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
// Select an integer feature
await userEvent.click(screen.getByRole('checkbox', { name: /maximum users/i }));
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
expect(lastCall).toHaveLength(2);
expect(lastCall).toContainEqual({
feature_code: 'sms_enabled',
bool_value: true,
int_value: null,
});
expect(lastCall).toContainEqual({
feature_code: 'max_users',
bool_value: null,
int_value: 0,
});
});
});
describe('Accessibility', () => {
it('has accessible labels for all checkboxes', () => {
render(<FeaturePicker {...defaultProps} />);
// Each feature should have an accessible checkbox
mockApiFeatures.forEach((feature) => {
const checkbox = screen.getByRole('checkbox', { name: new RegExp(feature.name, 'i') });
expect(checkbox).toBeInTheDocument();
});
});
it('integer input has accessible label', () => {
const selectedFeatures = [
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
];
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
const input = screen.getByDisplayValue('10');
expect(input).toHaveAttribute('aria-label');
});
});
});

View File

@@ -0,0 +1,412 @@
/**
* Tests for PlanEditorWizard Component Validation
*
* TDD: These tests define the expected validation behavior.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PlanEditorWizard } from '../PlanEditorWizard';
// Create a fresh query client for each test
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// Wrapper with QueryClientProvider
const createWrapper = () => {
const queryClient = createTestQueryClient();
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
// Mock the hooks
vi.mock('../../../hooks/useBillingAdmin', () => ({
useFeatures: () => ({
data: [
{ id: 1, code: 'sms_enabled', name: 'SMS', feature_type: 'boolean' },
{ id: 2, code: 'max_users', name: 'Max Users', feature_type: 'integer' },
],
isLoading: false,
}),
useAddOnProducts: () => ({
data: [{ id: 1, code: 'addon1', name: 'Add-on 1', is_active: true }],
isLoading: false,
}),
useCreatePlan: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1, code: 'test' }),
isPending: false,
}),
useCreatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1, version: 1 }),
isPending: false,
}),
useUpdatePlan: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
isPending: false,
}),
useUpdatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
isPending: false,
}),
}));
describe('PlanEditorWizard', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
mode: 'create' as const,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basics Step Validation', () => {
it('requires plan name to proceed', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Plan code is entered but name is empty
const codeInput = screen.getByLabelText(/plan code/i);
await user.type(codeInput, 'test_plan');
// Try to click Next
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).toBeDisabled();
});
it('requires plan code to proceed', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Name is entered but code is empty
const nameInput = screen.getByLabelText(/display name/i);
await user.type(nameInput, 'Test Plan');
// Next button should be disabled
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).toBeDisabled();
});
it('allows proceeding when code and name are provided', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Enter both code and name
const codeInput = screen.getByLabelText(/plan code/i);
const nameInput = screen.getByLabelText(/display name/i);
await user.type(codeInput, 'test_plan');
await user.type(nameInput, 'Test Plan');
// Next button should be enabled
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it('sanitizes plan code to lowercase with no spaces', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
const codeInput = screen.getByLabelText(/plan code/i);
await user.type(codeInput, 'My Test Plan');
// Should be sanitized
expect(codeInput).toHaveValue('mytestplan');
});
});
describe('Pricing Step Validation', () => {
const goToPricingStep = async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
// Go to pricing step
await user.click(screen.getByRole('button', { name: /next/i }));
return user;
};
it('shows pricing step inputs', async () => {
await goToPricingStep();
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
expect(screen.getByLabelText(/yearly price/i)).toBeInTheDocument();
});
it('does not allow negative monthly price', async () => {
const user = await goToPricingStep();
const monthlyInput = screen.getByLabelText(/monthly price/i);
await user.clear(monthlyInput);
await user.type(monthlyInput, '-50');
// Should show validation error or prevent input
// The input type="number" with min="0" should prevent negative values
expect(monthlyInput).toHaveAttribute('min', '0');
});
it('does not allow negative yearly price', async () => {
const user = await goToPricingStep();
const yearlyInput = screen.getByLabelText(/yearly price/i);
await user.clear(yearlyInput);
await user.type(yearlyInput, '-100');
// Should have min attribute set
expect(yearlyInput).toHaveAttribute('min', '0');
});
it('displays derived monthly equivalent for yearly price', async () => {
const user = await goToPricingStep();
const yearlyInput = screen.getByLabelText(/yearly price/i);
await user.clear(yearlyInput);
await user.type(yearlyInput, '120');
// Should show the monthly equivalent ($10/mo)
expect(screen.getByText(/\$10.*mo/i)).toBeInTheDocument();
});
});
describe('Transaction Fees Validation', () => {
const goToPricingStep = async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics and navigate
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i }));
return user;
};
it('validates fee percent is between 0 and 100', async () => {
const user = await goToPricingStep();
const feePercentInput = screen.getByLabelText(/fee percentage/i);
// Should have min and max attributes
expect(feePercentInput).toHaveAttribute('min', '0');
expect(feePercentInput).toHaveAttribute('max', '100');
});
it('does not allow fee percent over 100', async () => {
const user = await goToPricingStep();
const feePercentInput = screen.getByLabelText(/fee percentage/i);
await user.clear(feePercentInput);
await user.type(feePercentInput, '150');
// Should show validation warning
expect(screen.getByText(/must be between 0 and 100/i)).toBeInTheDocument();
});
it('does not allow negative fee percent', async () => {
const user = await goToPricingStep();
const feePercentInput = screen.getByLabelText(/fee percentage/i);
// Input has min="0" attribute to prevent negative values
expect(feePercentInput).toHaveAttribute('min', '0');
});
it('shows transaction fee example calculation', async () => {
const user = await goToPricingStep();
// Should show example like "On a $100 transaction: $4.40 fee"
expect(screen.getByText(/on a.*transaction/i)).toBeInTheDocument();
});
});
describe('Wizard Navigation', () => {
it('shows all wizard steps', () => {
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Should show step indicators (they have aria-label)
expect(screen.getByRole('button', { name: /basics/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /pricing/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /features/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /display/i })).toBeInTheDocument();
});
it('navigates back from pricing to basics', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics and go to pricing
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i }));
// Should be on pricing step
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
// Click back
await user.click(screen.getByRole('button', { name: /back/i }));
// Should be back on basics
expect(screen.getByLabelText(/plan code/i)).toBeInTheDocument();
});
it('allows clicking step indicators to navigate', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
// Click on Pricing step indicator
const pricingStep = screen.getByRole('button', { name: /pricing/i });
await user.click(pricingStep);
// Should navigate to pricing
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
});
});
describe('Live Summary Panel', () => {
it('shows plan name in summary', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
await user.type(screen.getByLabelText(/display name/i), 'My Amazing Plan');
// Summary should show the plan name
expect(screen.getByText('My Amazing Plan')).toBeInTheDocument();
});
it('shows price in summary after entering pricing', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Fill basics
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i }));
// Enter price
const monthlyInput = screen.getByLabelText(/monthly price/i);
await user.clear(monthlyInput);
await user.type(monthlyInput, '29');
// Summary should show the price
expect(screen.getByText(/\$29/)).toBeInTheDocument();
});
it('shows selected features count in summary', async () => {
const user = userEvent.setup();
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
// Navigate to features step
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
await user.click(screen.getByRole('button', { name: /next/i })); // to pricing
await user.click(screen.getByRole('button', { name: /next/i })); // to features
// Select a feature
const smsCheckbox = screen.getByRole('checkbox', { name: /sms/i });
await user.click(smsCheckbox);
// Summary should show feature count
expect(screen.getByText(/1 feature/i)).toBeInTheDocument();
});
});
describe('Create Version Confirmation', () => {
it('shows grandfathering warning when editing version with subscribers', async () => {
render(
<PlanEditorWizard
isOpen={true}
onClose={vi.fn()}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Should show warning about subscribers and grandfathering
expect(screen.getByText(/5/)).toBeInTheDocument();
expect(screen.getByText(/subscriber/i)).toBeInTheDocument();
expect(screen.getByText(/grandfathering/i)).toBeInTheDocument();
});
it('shows "Create New Version" confirmation for version with subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step and try to save
// The save button should mention "Create New Version"
const saveButton = screen.queryByRole('button', { name: /create new version/i });
expect(saveButton || screen.getByText(/new version/i)).toBeInTheDocument();
});
});
describe('Form Submission', () => {
it('calls onClose after successful creation', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<PlanEditorWizard {...defaultProps} onClose={onClose} />,
{ wrapper: createWrapper() }
);
// Fill all required fields
await user.type(screen.getByLabelText(/plan code/i), 'test');
await user.type(screen.getByLabelText(/display name/i), 'Test');
// Navigate through wizard
await user.click(screen.getByRole('button', { name: /next/i })); // pricing
await user.click(screen.getByRole('button', { name: /next/i })); // features
await user.click(screen.getByRole('button', { name: /next/i })); // display
// Submit
const createButton = screen.getByRole('button', { name: /create plan/i });
await user.click(createButton);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,445 @@
/**
* Canonical Feature Catalog
*
* This file defines the canonical list of features available in the SmoothSchedule
* billing system. Features are organized by type (boolean vs integer) and include
* human-readable labels and descriptions.
*
* IMPORTANT: When adding new feature codes, add them here first to maintain a
* single source of truth. The FeaturePicker component uses this catalog to
* provide autocomplete and validation.
*
* Feature Types:
* - Boolean: On/off capabilities (e.g., sms_enabled, api_access)
* - Integer: Limit/quota features (e.g., max_users, max_resources)
*
* Usage:
* ```typescript
* import { FEATURE_CATALOG, getFeatureInfo, isCanonicalFeature } from '../billing/featureCatalog';
*
* // Get info about a feature
* const info = getFeatureInfo('max_users');
* // { code: 'max_users', name: 'Maximum Users', type: 'integer', ... }
*
* // Check if a feature is in the canonical catalog
* const isCanonical = isCanonicalFeature('custom_feature'); // false
* ```
*/
export type FeatureType = 'boolean' | 'integer';
export interface FeatureCatalogEntry {
code: string;
name: string;
description: string;
type: FeatureType;
category: FeatureCategory;
}
export type FeatureCategory =
| 'communication'
| 'limits'
| 'access'
| 'branding'
| 'support'
| 'integrations'
| 'security'
| 'scheduling';
// =============================================================================
// Boolean Features (Capabilities)
// =============================================================================
export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
// Communication
{
code: 'sms_enabled',
name: 'SMS Messaging',
description: 'Send SMS notifications and reminders to customers',
type: 'boolean',
category: 'communication',
},
{
code: 'masked_calling_enabled',
name: 'Masked Calling',
description: 'Make calls with masked caller ID for privacy',
type: 'boolean',
category: 'communication',
},
{
code: 'proxy_number_enabled',
name: 'Proxy Phone Numbers',
description: 'Use proxy phone numbers for customer communication',
type: 'boolean',
category: 'communication',
},
// Payments & Commerce
{
code: 'can_accept_payments',
name: 'Accept Payments',
description: 'Accept online payments via Stripe Connect',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_pos',
name: 'Point of Sale',
description: 'Use Point of Sale (POS) system',
type: 'boolean',
category: 'access',
},
// Scheduling & Booking
{
code: 'recurring_appointments',
name: 'Recurring Appointments',
description: 'Schedule recurring appointments',
type: 'boolean',
category: 'scheduling',
},
{
code: 'group_bookings',
name: 'Group Bookings',
description: 'Allow multiple customers per appointment',
type: 'boolean',
category: 'scheduling',
},
{
code: 'waitlist',
name: 'Waitlist',
description: 'Enable waitlist for fully booked slots',
type: 'boolean',
category: 'scheduling',
},
{
code: 'can_add_video_conferencing',
name: 'Video Conferencing',
description: 'Add video conferencing to events',
type: 'boolean',
category: 'scheduling',
},
// Access & Features
{
code: 'api_access',
name: 'API Access',
description: 'Access the public API for integrations',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_analytics',
name: 'Analytics Dashboard',
description: 'Access business analytics and reporting',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_tasks',
name: 'Automated Tasks',
description: 'Create and run automated task workflows',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_contracts',
name: 'Contracts & E-Signatures',
description: 'Create and manage e-signature contracts',
type: 'boolean',
category: 'access',
},
{
code: 'customer_portal',
name: 'Customer Portal',
description: 'Branded self-service portal for customers',
type: 'boolean',
category: 'access',
},
{
code: 'custom_fields',
name: 'Custom Fields',
description: 'Create custom data fields for resources and events',
type: 'boolean',
category: 'access',
},
{
code: 'can_export_data',
name: 'Data Export',
description: 'Export data (appointments, customers, etc.)',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_mobile_app',
name: 'Mobile App',
description: 'Access the mobile app for field employees',
type: 'boolean',
category: 'access',
},
// Integrations
{
code: 'calendar_sync',
name: 'Calendar Sync',
description: 'Sync with Google Calendar, Outlook, etc.',
type: 'boolean',
category: 'integrations',
},
{
code: 'webhooks_enabled',
name: 'Webhooks',
description: 'Send webhook notifications for events',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_use_plugins',
name: 'Plugin Integrations',
description: 'Use third-party plugin integrations',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_create_plugins',
name: 'Create Plugins',
description: 'Create custom plugins for automation',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_manage_oauth_credentials',
name: 'Manage OAuth',
description: 'Manage your own OAuth credentials',
type: 'boolean',
category: 'integrations',
},
// Branding
{
code: 'custom_branding',
name: 'Custom Branding',
description: 'Customize branding colors, logo, and styling',
type: 'boolean',
category: 'branding',
},
{
code: 'white_label',
name: 'White Label',
description: 'Remove SmoothSchedule branding completely',
type: 'boolean',
category: 'branding',
},
{
code: 'can_use_custom_domain',
name: 'Custom Domain',
description: 'Configure a custom domain for your booking page',
type: 'boolean',
category: 'branding',
},
// Support
{
code: 'priority_support',
name: 'Priority Support',
description: 'Get priority customer support response',
type: 'boolean',
category: 'support',
},
// Security & Compliance
{
code: 'can_require_2fa',
name: 'Require 2FA',
description: 'Require two-factor authentication for users',
type: 'boolean',
category: 'security',
},
{
code: 'sso_enabled',
name: 'Single Sign-On (SSO)',
description: 'Enable SSO authentication for team members',
type: 'boolean',
category: 'security',
},
{
code: 'can_delete_data',
name: 'Delete Data',
description: 'Permanently delete data',
type: 'boolean',
category: 'security',
},
{
code: 'can_download_logs',
name: 'Download Logs',
description: 'Download system logs',
type: 'boolean',
category: 'security',
},
];
// =============================================================================
// Integer Features (Limits & Quotas)
// =============================================================================
export const INTEGER_FEATURES: FeatureCatalogEntry[] = [
// User/Resource Limits
{
code: 'max_users',
name: 'Maximum Team Members',
description: 'Maximum number of team member accounts (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_resources',
name: 'Maximum Resources',
description: 'Maximum number of resources (staff, rooms, equipment)',
type: 'integer',
category: 'limits',
},
{
code: 'max_locations',
name: 'Location Limit',
description: 'Maximum number of business locations (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_services',
name: 'Maximum Services',
description: 'Maximum number of service types (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_customers',
name: 'Customer Limit',
description: 'Maximum number of customer records (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_event_types',
name: 'Max Event Types',
description: 'Maximum number of event types',
type: 'integer',
category: 'limits',
},
// Usage Limits
{
code: 'max_appointments_per_month',
name: 'Monthly Appointment Limit',
description: 'Maximum appointments per month (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_automated_tasks',
name: 'Automated Task Limit',
description: 'Maximum number of automated tasks (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_email_templates',
name: 'Email Template Limit',
description: 'Maximum number of custom email templates (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_calendars_connected',
name: 'Max Calendars',
description: 'Maximum number of external calendars connected',
type: 'integer',
category: 'limits',
},
// Technical Limits
{
code: 'storage_gb',
name: 'Storage (GB)',
description: 'File storage limit in gigabytes (0 = unlimited)',
type: 'integer',
category: 'limits',
},
{
code: 'max_api_requests_per_day',
name: 'Daily API Request Limit',
description: 'Maximum API requests per day (0 = unlimited)',
type: 'integer',
category: 'limits',
},
];
// =============================================================================
// Combined Catalog
// =============================================================================
export const FEATURE_CATALOG: FeatureCatalogEntry[] = [
...BOOLEAN_FEATURES,
...INTEGER_FEATURES,
];
// Create a lookup map for quick access
const featureMap = new Map<string, FeatureCatalogEntry>(
FEATURE_CATALOG.map((f) => [f.code, f])
);
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get feature information by code
*/
export const getFeatureInfo = (code: string): FeatureCatalogEntry | undefined => {
return featureMap.get(code);
};
/**
* Check if a feature code is in the canonical catalog
*/
export const isCanonicalFeature = (code: string): boolean => {
return featureMap.has(code);
};
/**
* Get all features by type
*/
export const getFeaturesByType = (type: FeatureType): FeatureCatalogEntry[] => {
return FEATURE_CATALOG.filter((f) => f.type === type);
};
/**
* Get all features by category
*/
export const getFeaturesByCategory = (category: FeatureCategory): FeatureCatalogEntry[] => {
return FEATURE_CATALOG.filter((f) => f.category === category);
};
/**
* Get all unique categories
*/
export const getAllCategories = (): FeatureCategory[] => {
return [...new Set(FEATURE_CATALOG.map((f) => f.category))];
};
/**
* Format category name for display
*/
export const formatCategoryName = (category: FeatureCategory): string => {
const names: Record<FeatureCategory, string> = {
communication: 'Communication',
limits: 'Limits & Quotas',
access: 'Access & Features',
branding: 'Branding & Customization',
support: 'Support',
integrations: 'Integrations',
security: 'Security & Compliance',
scheduling: 'Scheduling & Booking',
};
return names[category];
};

View File

@@ -0,0 +1,27 @@
/**
* Billing Module
*
* Components and utilities for the billing management system.
*
* Component Structure:
* - CatalogListPanel: Left sidebar with search/filter and item list
* - PlanDetailPanel: Main panel showing selected plan/addon details
* - PlanEditorWizard: Multi-step wizard for creating/editing plans
* - FeaturePicker: Feature selection UI for plans
*
* To add new feature codes:
* 1. Add the feature to featureCatalog.ts in BOOLEAN_FEATURES or INTEGER_FEATURES
* 2. Run migrations in backend if needed
* 3. Features in the catalog get validation and display benefits
*/
// Feature Catalog
export * from './featureCatalog';
// Components
export { FeaturePicker } from './components/FeaturePicker';
export { PlanEditorWizard } from './components/PlanEditorWizard';
export { CatalogListPanel } from './components/CatalogListPanel';
export { PlanDetailPanel } from './components/PlanDetailPanel';
export { AddOnEditorModal } from './components/AddOnEditorModal';
export type { CatalogItem } from './components/CatalogListPanel';

View File

@@ -175,6 +175,20 @@ export interface PlanCreate {
max_custom_domains?: number;
}
export interface AddOnFeature {
id: number;
feature: Feature;
bool_value: boolean | null;
int_value: number | null;
value: boolean | number | null;
}
export interface AddOnFeatureWrite {
feature_code: string;
bool_value?: boolean | null;
int_value?: number | null;
}
export interface AddOnProduct {
id: number;
code: string;
@@ -184,7 +198,9 @@ export interface AddOnProduct {
price_one_time_cents: number;
stripe_product_id: string;
stripe_price_id: string;
is_stackable: boolean;
is_active: boolean;
features: AddOnFeature[];
}
export interface AddOnProductCreate {
@@ -195,7 +211,9 @@ export interface AddOnProductCreate {
price_one_time_cents?: number;
stripe_product_id?: string;
stripe_price_id?: string;
is_stackable?: boolean;
is_active?: boolean;
features?: AddOnFeatureWrite[];
}
// Grandfathering response when updating a version with subscribers

View File

@@ -1,36 +1,360 @@
/**
* Billing Management Page
*
* Standalone page for managing subscription plans, features, and add-ons.
* Redesigned billing management with master-detail layout.
* Uses the commerce.billing system with versioning for grandfathering support.
*
* Current Entities & Endpoints:
* - Plans: /billing/admin/plans/ (CRUD)
* - PlanVersions: /billing/admin/plan-versions/ (CRUD, grandfathering)
* - Features: /billing/admin/features/ (CRUD)
* - AddOns: /billing/admin/addons/ (CRUD)
* - Entitlements: /me/entitlements/ (read only)
*
* Layout:
* - Top tabs: Catalog | Overrides | Invoices
* - Catalog tab has master-detail: left sidebar + main panel
*/
import React from 'react';
import { CreditCard } from 'lucide-react';
import BillingPlansTab from './components/BillingPlansTab';
import React, { useState, useMemo } from 'react';
import { CreditCard, Package, Shield, FileText, Loader2 } from 'lucide-react';
import {
usePlans,
useAddOnProducts,
type PlanWithVersions,
type PlanVersion,
type AddOnProduct,
} from '../../hooks/useBillingAdmin';
import {
CatalogListPanel,
PlanDetailPanel,
PlanEditorWizard,
AddOnEditorModal,
type CatalogItem,
} from '../../billing';
import { ErrorMessage, Alert } from '../../components/ui';
// Tab types
type MainTab = 'catalog' | 'overrides' | 'invoices';
const BillingManagement: React.FC = () => {
const [activeTab, setActiveTab] = useState<MainTab>('catalog');
const [selectedItem, setSelectedItem] = useState<CatalogItem | null>(null);
const [showPlanWizard, setShowPlanWizard] = useState(false);
const [showAddonModal, setShowAddonModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<PlanWithVersions | null>(null);
const [editingVersion, setEditingVersion] = useState<PlanVersion | null>(null);
const [editingAddon, setEditingAddon] = useState<AddOnProduct | null>(null);
// Fetch data
const { data: plans, isLoading: plansLoading, error: plansError } = usePlans();
const { data: addons, isLoading: addonsLoading, error: addonsError } = useAddOnProducts();
const isLoading = plansLoading || addonsLoading;
const error = plansError || addonsError;
// Convert plans and addons to catalog items
const catalogItems: CatalogItem[] = useMemo(() => {
const items: CatalogItem[] = [];
// Add plans
plans?.forEach((plan) => {
const activeVersion = plan.active_version;
items.push({
id: plan.id,
type: 'plan',
code: plan.code,
name: plan.name,
isActive: plan.is_active,
isPublic: activeVersion?.is_public ?? false,
isLegacy: activeVersion?.is_legacy ?? false,
priceMonthly: activeVersion?.price_monthly_cents,
priceYearly: activeVersion?.price_yearly_cents,
subscriberCount: plan.total_subscribers,
stripeProductId: activeVersion?.stripe_product_id,
});
});
// Add addons
addons?.forEach((addon) => {
items.push({
id: addon.id,
type: 'addon',
code: addon.code,
name: addon.name,
isActive: addon.is_active,
isPublic: true,
isLegacy: false,
priceMonthly: addon.price_monthly_cents,
stripeProductId: addon.stripe_product_id,
});
});
return items;
}, [plans, addons]);
// Find selected plan or addon
const selectedPlan = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'plan') return null;
return plans?.find((p) => p.id === selectedItem.id) || null;
}, [selectedItem, plans]);
const selectedAddon = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'addon') return null;
return addons?.find((a) => a.id === selectedItem.id) || null;
}, [selectedItem, addons]);
// Handlers
const handleCreatePlan = () => {
setEditingPlan(null);
setEditingVersion(null);
setShowPlanWizard(true);
};
const handleCreateAddon = () => {
setEditingAddon(null);
setShowAddonModal(true);
};
const handleEditAddon = () => {
if (selectedAddon) {
setEditingAddon(selectedAddon);
setShowAddonModal(true);
}
};
const handleCloseAddonModal = () => {
setShowAddonModal(false);
setEditingAddon(null);
};
const handleEditPlan = () => {
if (selectedPlan) {
setEditingPlan(selectedPlan);
setEditingVersion(selectedPlan.active_version);
setShowPlanWizard(true);
}
};
const handleDuplicatePlan = () => {
if (selectedPlan) {
// Open wizard with plan data but clear IDs
setEditingPlan(null);
// TODO: Pre-fill wizard with selected plan's data
setShowPlanWizard(true);
}
};
const handleCreateVersion = () => {
if (selectedPlan) {
setEditingPlan(selectedPlan);
setEditingVersion(null); // null = create new version
setShowPlanWizard(true);
}
};
const handleEditVersion = (version: PlanVersion) => {
if (selectedPlan) {
setEditingPlan(selectedPlan);
setEditingVersion(version);
setShowPlanWizard(true);
}
};
const handleCloseWizard = () => {
setShowPlanWizard(false);
setEditingPlan(null);
setEditingVersion(null);
};
const tabs = [
{ id: 'catalog' as const, label: 'Catalog', icon: Package },
{ id: 'overrides' as const, label: 'Overrides', icon: Shield },
{ id: 'invoices' as const, label: 'Invoices', icon: FileText },
];
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<CreditCard className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Billing Management
</h1>
<p className="text-gray-500 dark:text-gray-400">
Manage subscription plans, features, and add-ons
</p>
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<CreditCard className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Billing Management
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Manage subscription plans, features, and add-ons
</p>
</div>
</div>
</div>
{/* Main Tabs */}
<div className="flex gap-1 mt-4">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
isActive
? 'bg-gray-50 dark:bg-gray-900 text-blue-600 dark:text-blue-400 border-t border-l border-r border-gray-200 dark:border-gray-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
</div>
{/* Billing Plans Content */}
<BillingPlansTab />
{/* Tab Content */}
<div className="flex-1 overflow-hidden">
{/* Catalog Tab */}
{activeTab === 'catalog' && (
<div className="h-full flex">
{/* Left Sidebar */}
<div className="w-96 flex-shrink-0">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
) : error ? (
<div className="p-4">
<ErrorMessage message="Failed to load billing data" />
</div>
) : (
<CatalogListPanel
items={catalogItems}
selectedId={selectedItem?.id || null}
onSelect={setSelectedItem}
onCreatePlan={handleCreatePlan}
onCreateAddon={handleCreateAddon}
/>
)}
</div>
{/* Main Panel */}
<div className="flex-1 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700">
<PlanDetailPanel
plan={selectedPlan}
addon={selectedAddon}
onEdit={selectedAddon ? handleEditAddon : handleEditPlan}
onDuplicate={handleDuplicatePlan}
onCreateVersion={handleCreateVersion}
onEditVersion={handleEditVersion}
/>
</div>
</div>
)}
{/* Overrides Tab */}
{activeTab === 'overrides' && (
<div className="p-6">
<Alert
variant="info"
message={
<>
<strong>Entitlement Overrides</strong>
<p className="mt-1">
Overrides allow you to grant or revoke specific features for individual tenants.
Resolution order: Override &gt; Add-on &gt; Plan
</p>
</>
}
/>
<div className="mt-6 text-center text-gray-500 dark:text-gray-400 py-12">
<Shield className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Overrides management coming soon</p>
<p className="text-sm mt-1">
Use Django admin at /admin/billing/entitlementoverride/ for now
</p>
</div>
</div>
)}
{/* Invoices Tab */}
{activeTab === 'invoices' && (
<div className="p-6">
<Alert
variant="info"
message={
<>
<strong>Invoice Management</strong>
<p className="mt-1">
View and manage billing invoices for all tenants.
</p>
</>
}
/>
<div className="mt-6 text-center text-gray-500 dark:text-gray-400 py-12">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Invoice listing coming soon</p>
<p className="text-sm mt-1">
Use Django admin at /admin/billing/invoice/ for now
</p>
</div>
</div>
)}
</div>
{/* Plan Editor Wizard */}
{showPlanWizard && (
<PlanEditorWizard
isOpen={showPlanWizard}
onClose={handleCloseWizard}
mode={editingPlan ? 'edit' : 'create'}
initialData={
editingPlan
? {
id: editingPlan.id,
code: editingPlan.code,
name: editingPlan.name,
description: editingPlan.description,
display_order: editingPlan.display_order,
is_active: editingPlan.is_active,
version: editingVersion
? {
id: editingVersion.id,
name: editingVersion.name,
price_monthly_cents: editingVersion.price_monthly_cents,
price_yearly_cents: editingVersion.price_yearly_cents,
transaction_fee_percent: editingVersion.transaction_fee_percent,
transaction_fee_fixed_cents: editingVersion.transaction_fee_fixed_cents,
trial_days: editingVersion.trial_days,
is_public: editingVersion.is_public,
is_most_popular: editingVersion.is_most_popular,
show_price: editingVersion.show_price,
marketing_features: editingVersion.marketing_features,
stripe_product_id: editingVersion.stripe_product_id,
stripe_price_id_monthly: editingVersion.stripe_price_id_monthly,
stripe_price_id_yearly: editingVersion.stripe_price_id_yearly,
features: editingVersion.features,
subscriber_count: editingVersion.subscriber_count,
}
: undefined,
}
: undefined
}
/>
)}
{/* Add-On Editor Modal */}
{showAddonModal && (
<AddOnEditorModal
isOpen={showAddonModal}
onClose={handleCloseAddonModal}
addon={editingAddon}
/>
)}
</div>
);
};