Add stackable add-ons with compounding integer features

- Add is_stackable field to AddOnProduct model for add-ons that can be
  purchased multiple times
- Add quantity field to SubscriptionAddOn for tracking purchase count
- Update EntitlementService to ADD integer add-on values to base plan
  (instead of max) and multiply by quantity for stackable add-ons
- Add feature selection to AddOnEditorModal using FeaturePicker component
- Add AddOnFeatureSerializer for nested feature CRUD on add-ons
- Fix Create Add-on button styling to use solid blue (was muted outline)
- Widen billing sidebar from 320px to 384px to prevent text wrapping

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-12 03:10:53 -05:00
parent 6afa3d7415
commit a8c271b5e3
18 changed files with 4715 additions and 36 deletions

View File

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