diff --git a/frontend/src/api/__tests__/billing.test.ts b/frontend/src/api/__tests__/billing.test.ts index 9d3983d..6321012 100644 --- a/frontend/src/api/__tests__/billing.test.ts +++ b/frontend/src/api/__tests__/billing.test.ts @@ -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, }, ]; diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts index 2659acd..955ab0f 100644 --- a/frontend/src/api/billing.ts +++ b/frontend/src/api/billing.ts @@ -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; } diff --git a/frontend/src/billing/components/AddOnEditorModal.tsx b/frontend/src/billing/components/AddOnEditorModal.tsx new file mode 100644 index 0000000..52b5638 --- /dev/null +++ b/frontend/src/billing/components/AddOnEditorModal.tsx @@ -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 = ({ + isOpen, + onClose, + addon, +}) => { + const isEditMode = !!addon; + + const [formData, setFormData] = useState({ + 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>>({}); + + // 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> = {}; + + 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 ( + +
+ {/* Basic Info */} +
+

Basic Information

+ +
+ handleChange('code', e.target.value)} + error={errors.code} + placeholder="sms_credits_pack" + disabled={isEditMode} + hint={isEditMode ? 'Code cannot be changed' : 'Unique identifier (lowercase, underscores)'} + /> + handleChange('name', e.target.value)} + error={errors.name} + placeholder="SMS Credits Pack" + /> +
+ +
+ +