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:
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
376
frontend/src/billing/components/AddOnEditorModal.tsx
Normal file
376
frontend/src/billing/components/AddOnEditorModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
337
frontend/src/billing/components/CatalogListPanel.tsx
Normal file
337
frontend/src/billing/components/CatalogListPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
271
frontend/src/billing/components/FeaturePicker.tsx
Normal file
271
frontend/src/billing/components/FeaturePicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
609
frontend/src/billing/components/PlanDetailPanel.tsx
Normal file
609
frontend/src/billing/components/PlanDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
881
frontend/src/billing/components/PlanEditorWizard.tsx
Normal file
881
frontend/src/billing/components/PlanEditorWizard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal file
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
445
frontend/src/billing/featureCatalog.ts
Normal file
445
frontend/src/billing/featureCatalog.ts
Normal 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];
|
||||
};
|
||||
27
frontend/src/billing/index.ts
Normal file
27
frontend/src/billing/index.ts
Normal 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';
|
||||
@@ -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
|
||||
|
||||
@@ -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 > Add-on > 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ DRF serializers for billing API endpoints.
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from smoothschedule.billing.models import AddOnFeature
|
||||
from smoothschedule.billing.models import AddOnProduct
|
||||
from smoothschedule.billing.models import Feature
|
||||
from smoothschedule.billing.models import Invoice
|
||||
@@ -114,9 +115,26 @@ class PlanVersionSummarySerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class AddOnFeatureSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for AddOnFeature model."""
|
||||
|
||||
feature = FeatureSerializer(read_only=True)
|
||||
value = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AddOnFeature
|
||||
fields = ["id", "feature", "bool_value", "int_value", "value"]
|
||||
|
||||
def get_value(self, obj):
|
||||
"""Return the effective value based on feature type."""
|
||||
return obj.get_value()
|
||||
|
||||
|
||||
class AddOnProductSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for AddOnProduct model."""
|
||||
|
||||
features = AddOnFeatureSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AddOnProduct
|
||||
fields = [
|
||||
@@ -126,7 +144,11 @@ class AddOnProductSerializer(serializers.ModelSerializer):
|
||||
"description",
|
||||
"price_monthly_cents",
|
||||
"price_one_time_cents",
|
||||
"stripe_product_id",
|
||||
"stripe_price_id",
|
||||
"is_stackable",
|
||||
"is_active",
|
||||
"features",
|
||||
]
|
||||
|
||||
|
||||
@@ -142,6 +164,7 @@ class SubscriptionAddOnSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"addon",
|
||||
"status",
|
||||
"quantity",
|
||||
"activated_at",
|
||||
"expires_at",
|
||||
"is_active",
|
||||
@@ -402,6 +425,33 @@ class PlanVersionUpdateSerializer(serializers.ModelSerializer):
|
||||
"features",
|
||||
]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
features_data = validated_data.pop("features", None)
|
||||
|
||||
# Update the instance fields
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
# Update features if provided
|
||||
if features_data is not None:
|
||||
# Clear existing features and recreate
|
||||
instance.features.all().delete()
|
||||
for feature_data in features_data:
|
||||
try:
|
||||
feature = Feature.objects.get(code=feature_data["feature_code"])
|
||||
except Feature.DoesNotExist:
|
||||
continue # Skip unknown features
|
||||
|
||||
PlanFeature.objects.create(
|
||||
plan_version=instance,
|
||||
feature=feature,
|
||||
bool_value=feature_data.get("bool_value"),
|
||||
int_value=feature_data.get("int_value"),
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class PlanVersionDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed serializer for PlanVersion with subscriber count.
|
||||
@@ -463,9 +513,19 @@ class PlanVersionDetailSerializer(serializers.ModelSerializer):
|
||||
).count()
|
||||
|
||||
|
||||
class AddOnFeatureWriteSerializer(serializers.Serializer):
|
||||
"""Serializer for writing add-on features."""
|
||||
|
||||
feature_code = serializers.CharField()
|
||||
bool_value = serializers.BooleanField(required=False, allow_null=True)
|
||||
int_value = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class AddOnProductCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating/updating AddOnProducts."""
|
||||
|
||||
features = AddOnFeatureWriteSerializer(many=True, required=False, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AddOnProduct
|
||||
fields = [
|
||||
@@ -476,9 +536,60 @@ class AddOnProductCreateSerializer(serializers.ModelSerializer):
|
||||
"price_one_time_cents",
|
||||
"stripe_product_id",
|
||||
"stripe_price_id",
|
||||
"is_stackable",
|
||||
"is_active",
|
||||
"features",
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
features_data = validated_data.pop("features", [])
|
||||
|
||||
# Create the add-on
|
||||
addon = AddOnProduct.objects.create(**validated_data)
|
||||
|
||||
# Create add-on features
|
||||
for feature_data in features_data:
|
||||
try:
|
||||
feature = Feature.objects.get(code=feature_data["feature_code"])
|
||||
except Feature.DoesNotExist:
|
||||
continue # Skip unknown features
|
||||
|
||||
AddOnFeature.objects.create(
|
||||
addon=addon,
|
||||
feature=feature,
|
||||
bool_value=feature_data.get("bool_value"),
|
||||
int_value=feature_data.get("int_value"),
|
||||
)
|
||||
|
||||
return addon
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
features_data = validated_data.pop("features", None)
|
||||
|
||||
# Update the instance fields
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
# Update features if provided
|
||||
if features_data is not None:
|
||||
# Clear existing features and recreate
|
||||
instance.features.all().delete()
|
||||
for feature_data in features_data:
|
||||
try:
|
||||
feature = Feature.objects.get(code=feature_data["feature_code"])
|
||||
except Feature.DoesNotExist:
|
||||
continue # Skip unknown features
|
||||
|
||||
AddOnFeature.objects.create(
|
||||
addon=instance,
|
||||
feature=feature,
|
||||
bool_value=feature_data.get("bool_value"),
|
||||
int_value=feature_data.get("int_value"),
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class PlanWithVersionsSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Plan with all its versions."""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-12 07:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0009_remove_business_tier'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='addonproduct',
|
||||
name='is_stackable',
|
||||
field=models.BooleanField(default=False, help_text='If True, can be purchased multiple times. Integer features compound.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subscriptionaddon',
|
||||
name='quantity',
|
||||
field=models.PositiveIntegerField(default=1, help_text='Number of units purchased. Only applies to stackable add-ons.'),
|
||||
),
|
||||
]
|
||||
@@ -259,6 +259,10 @@ class AddOnProduct(models.Model):
|
||||
Add-ons can be:
|
||||
- Monthly recurring (price_monthly_cents > 0)
|
||||
- One-time purchase (price_one_time_cents > 0)
|
||||
|
||||
Stackable add-ons can be purchased multiple times and their integer
|
||||
feature values compound (e.g., 3x "5 extra resources" = +15 resources).
|
||||
Non-stackable add-ons can only be purchased once per subscription.
|
||||
"""
|
||||
|
||||
code = models.CharField(max_length=50, unique=True, db_index=True)
|
||||
@@ -273,6 +277,12 @@ class AddOnProduct(models.Model):
|
||||
stripe_product_id = models.CharField(max_length=100, blank=True)
|
||||
stripe_price_id = models.CharField(max_length=100, blank=True)
|
||||
|
||||
# Stacking behavior
|
||||
is_stackable = models.BooleanField(
|
||||
default=False,
|
||||
help_text="If True, can be purchased multiple times. Integer features compound.",
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -317,6 +327,9 @@ class SubscriptionAddOn(models.Model):
|
||||
|
||||
Tracks when add-ons were activated, when they expire,
|
||||
and their Stripe subscription item ID.
|
||||
|
||||
For stackable add-ons, quantity tracks how many are purchased.
|
||||
Integer feature values are multiplied by quantity during entitlement resolution.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
@@ -331,6 +344,12 @@ class SubscriptionAddOn(models.Model):
|
||||
addon = models.ForeignKey(AddOnProduct, on_delete=models.PROTECT)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="active")
|
||||
|
||||
# Quantity for stackable add-ons (e.g., 3x "5 extra resources")
|
||||
quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Number of units purchased. Only applies to stackable add-ons.",
|
||||
)
|
||||
|
||||
activated_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
canceled_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
@@ -6,8 +6,10 @@ Resolution order (highest to lowest precedence):
|
||||
2. Active, non-expired SubscriptionAddOn features
|
||||
3. Base PlanVersion features from Subscription
|
||||
|
||||
For integer features (limits), when multiple sources grant the same feature,
|
||||
the highest value wins.
|
||||
For integer features (limits):
|
||||
- Add-on values are ADDED to the base plan value (not max)
|
||||
- Stackable add-on values are multiplied by quantity
|
||||
- Example: Plan gives 10 resources + add-on gives 5 resources (qty 3) = 25 total
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,11 +30,16 @@ class EntitlementService:
|
||||
|
||||
Resolution order (highest to lowest precedence):
|
||||
1. Active, non-expired EntitlementOverrides
|
||||
2. Active, non-expired SubscriptionAddOn features
|
||||
2. Active, non-expired SubscriptionAddOn features (ADDED to plan)
|
||||
3. Base PlanVersion features from Subscription
|
||||
|
||||
For integer features, when multiple sources grant the same feature,
|
||||
the highest value wins (except overrides which always take precedence).
|
||||
For integer features:
|
||||
- Add-on values are ADDED to the base plan value
|
||||
- Stackable add-on values are multiplied by their quantity
|
||||
- Example: Plan (10) + AddOn (5) x 3 qty = 25 total
|
||||
|
||||
For boolean features:
|
||||
- True from any source wins
|
||||
|
||||
Args:
|
||||
business: The Tenant to get entitlements for
|
||||
@@ -57,27 +64,36 @@ class EntitlementService:
|
||||
for pf in plan_features:
|
||||
result[pf.feature.code] = pf.get_value()
|
||||
|
||||
# Layer 2: Add-on features (stacks on top of plan)
|
||||
# Layer 2: Add-on features (ADDED to plan values for integers)
|
||||
# For boolean: any True wins
|
||||
# For integer: highest value wins
|
||||
active_addons = subscription.addons.filter(status="active").all()
|
||||
# For integer: values are ADDED to base plan value (multiplied by quantity)
|
||||
active_addons = subscription.addons.filter(status="active").select_related(
|
||||
"addon"
|
||||
).all()
|
||||
for subscription_addon in active_addons:
|
||||
if not subscription_addon.is_active:
|
||||
continue
|
||||
|
||||
for af in subscription_addon.addon.features.all():
|
||||
quantity = subscription_addon.quantity if subscription_addon.addon.is_stackable else 1
|
||||
|
||||
for af in subscription_addon.addon.features.select_related("feature").all():
|
||||
feature_code = af.feature.code
|
||||
addon_value = af.get_value()
|
||||
|
||||
if feature_code not in result:
|
||||
result[feature_code] = addon_value
|
||||
elif af.feature.feature_type == "integer":
|
||||
# For integer features, take the max value
|
||||
if addon_value is None:
|
||||
continue
|
||||
|
||||
if af.feature.feature_type == "integer":
|
||||
# For integer features: ADD to current value (multiplied by quantity)
|
||||
effective_addon_value = addon_value * quantity
|
||||
current = result.get(feature_code)
|
||||
if current is None or (
|
||||
addon_value is not None and addon_value > current
|
||||
):
|
||||
result[feature_code] = addon_value
|
||||
if current is None:
|
||||
result[feature_code] = effective_addon_value
|
||||
elif isinstance(current, int):
|
||||
result[feature_code] = current + effective_addon_value
|
||||
else:
|
||||
# Current value is not an int (shouldn't happen), set it
|
||||
result[feature_code] = effective_addon_value
|
||||
elif af.feature.feature_type == "boolean":
|
||||
# For boolean features, True wins over False
|
||||
if addon_value is True:
|
||||
|
||||
Reference in New Issue
Block a user