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:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user