feat: add feature assignment to admin tier management UI

- Add slide-over panel for assigning features to subscription tiers
- Features grouped by category with select all/deselect all
- Add puzzle-piece icon button in tier table actions
- Add feature management methods to subscription-tiers.js
- Fix JS-006 by adding try/catch to init function

Documentation:
- Update feature-gating-system.md with Admin Tier Management UI section
- Update subscription-billing.md with tier management overview
- Add new admin user guide: subscription-tier-management.md
- Add guide to mkdocs.yml navigation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 12:58:09 +01:00
parent 71c66aa237
commit d803e1c911
6 changed files with 501 additions and 3 deletions

View File

@@ -144,6 +144,9 @@
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openFeaturePanel(tier)" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="Edit Features">
<span x-html="$icon('puzzle-piece', 'w-4 h-4')"></span>
</button>
<button @click="openEditModal(tier)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
@@ -332,6 +335,143 @@
</div>
</div>
<!-- Feature Assignment Slide-over Panel -->
<div
x-show="showFeaturePanel"
x-cloak
@keydown.escape.window="closeFeaturePanel()"
class="fixed inset-0 z-50 overflow-hidden"
role="dialog"
aria-modal="true"
>
<!-- Backdrop -->
<div
x-show="showFeaturePanel"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
@click="closeFeaturePanel()"
></div>
<!-- Panel -->
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
<div
x-show="showFeaturePanel"
x-transition:enter="transform transition ease-in-out duration-300"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-300"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="w-screen max-w-lg"
>
<div class="flex h-full flex-col bg-white dark:bg-gray-800 shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Edit Features</h2>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedTierForFeatures?.name"></p>
</div>
<button
@click="closeFeaturePanel()"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Loading State -->
<div x-show="loadingFeatures" class="flex items-center justify-center py-8">
<span x-html="$icon('refresh', 'w-6 h-6 animate-spin text-purple-600')"></span>
<span class="ml-2 text-gray-500 dark:text-gray-400">Loading features...</span>
</div>
<!-- Feature Categories -->
<div x-show="!loadingFeatures" class="space-y-6">
<template x-for="category in categories" :key="category">
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<!-- Category Header -->
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-700/50">
<h3 class="text-sm font-medium text-gray-900 dark:text-white" x-text="formatCategoryName(category)"></h3>
<div class="flex items-center gap-2">
<button
@click="selectAllInCategory(category)"
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
x-show="!allSelectedInCategory(category)"
>
Select all
</button>
<button
@click="deselectAllInCategory(category)"
class="text-xs text-gray-500 dark:text-gray-400 hover:underline"
x-show="allSelectedInCategory(category)"
>
Deselect all
</button>
</div>
</div>
<!-- Features List -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="feature in featuresGrouped[category]" :key="feature.code">
<label class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer">
<input
type="checkbox"
:checked="isFeatureSelected(feature.code)"
@change="toggleFeature(feature.code)"
class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description"></div>
</div>
</label>
</template>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="categories.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
No features available.
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="selectedFeatures.length"></span> features selected
</div>
<div class="flex items-center gap-3">
<button
@click="closeFeaturePanel()"
type="button"
class="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 transition-colors"
>
Cancel
</button>
<button
@click="saveFeatures()"
:disabled="savingFeatures"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
<span x-show="savingFeatures" x-html="$icon('refresh', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="savingFeatures ? 'Saving...' : 'Save Features'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}

View File

@@ -137,6 +137,26 @@ tier.features = [
]
```
### Admin Tier Management
Administrators can manage subscription tiers at `/admin/subscription-tiers`:
**Capabilities:**
- View all tiers with stats (total, active, public, MRR)
- Create new tiers with custom pricing and limits
- Edit tier properties (name, pricing, limits, Stripe IDs)
- Activate/deactivate tiers
- Assign features to tiers via slide-over panel
**Feature Assignment:**
1. Click the puzzle-piece icon on any tier row
2. Features are displayed grouped by category
3. Use checkboxes to select/deselect features
4. Use "Select all" / "Deselect all" per category
5. Click "Save Features" to update
See [Feature Gating System](../implementation/feature-gating-system.md#admin-tier-management-ui) for technical details.
## Limit Enforcement
Limits are enforced at the service layer:

View File

@@ -0,0 +1,135 @@
# Subscription Tier Management
This guide explains how to manage subscription tiers and assign features to them in the admin panel.
## Accessing Tier Management
Navigate to **Admin → Billing & Subscriptions → Subscription Tiers** or go directly to `/admin/subscription-tiers`.
## Dashboard Overview
The tier management page displays:
### Stats Cards
- **Total Tiers**: Number of configured subscription tiers
- **Active Tiers**: Tiers currently available for subscription
- **Public Tiers**: Tiers visible to vendors (excludes enterprise/custom)
- **Est. MRR**: Estimated Monthly Recurring Revenue
### Tier Table
Each tier shows:
| Column | Description |
|--------|-------------|
| # | Display order (affects pricing page order) |
| Code | Unique identifier (e.g., `essential`, `professional`) |
| Name | Display name shown to vendors |
| Monthly | Monthly price in EUR |
| Annual | Annual price in EUR (or `-` if not set) |
| Orders/Mo | Monthly order limit (or `Unlimited`) |
| Products | Product limit (or `Unlimited`) |
| Team | Team member limit (or `Unlimited`) |
| Features | Number of features assigned |
| Status | Active, Private, or Inactive |
| Actions | Edit Features, Edit, Activate/Deactivate |
## Managing Tiers
### Creating a New Tier
1. Click **Create Tier** button
2. Fill in the tier details:
- **Code**: Unique lowercase identifier (cannot be changed after creation)
- **Name**: Display name for the tier
- **Monthly Price**: Price in cents (e.g., 4900 for €49.00)
- **Annual Price**: Optional annual price in cents
- **Order Limit**: Leave empty for unlimited
- **Product Limit**: Leave empty for unlimited
- **Team Members**: Leave empty for unlimited
- **Display Order**: Controls sort order on pricing pages
- **Active**: Whether tier is available
- **Public**: Whether tier is visible to vendors
3. Click **Create**
### Editing a Tier
1. Click the **pencil icon** on the tier row
2. Modify the tier properties
3. Click **Update**
Note: The tier code cannot be changed after creation.
### Activating/Deactivating Tiers
- Click the **check-circle icon** to activate an inactive tier
- Click the **x-circle icon** to deactivate an active tier
Deactivating a tier:
- Does not affect existing subscriptions
- Hides the tier from new subscription selection
- Can be reactivated at any time
## Managing Features
### Assigning Features to a Tier
1. Click the **puzzle-piece icon** on the tier row
2. A slide-over panel opens showing all available features
3. Features are grouped by category:
- Analytics
- Product Management
- Order Management
- Marketing
- Support
- Integration
- Branding
- Team
4. Check/uncheck features to include in the tier
5. Use **Select all** or **Deselect all** per category for bulk actions
6. The footer shows the total number of selected features
7. Click **Save Features** to apply changes
### Feature Categories
| Category | Example Features |
|----------|------------------|
| Analytics | Basic Analytics, Analytics Dashboard, Custom Reports |
| Product Management | Bulk Edit, Variants, Bundles, Inventory Alerts |
| Order Management | Order Automation, Advanced Fulfillment, Multi-Warehouse |
| Marketing | Discount Codes, Abandoned Cart, Email Marketing, Loyalty |
| Support | Email Support, Priority Support, Phone Support, Dedicated Manager |
| Integration | Basic API, Advanced API, Webhooks, Custom Integrations |
| Branding | Theme Customization, Custom Domain, White Label |
| Team | Team Management, Role Permissions, Audit Logs |
## Best Practices
### Tier Pricing Strategy
1. **Essential**: Entry-level with basic features and limits
2. **Professional**: Mid-tier with increased limits and key integrations
3. **Business**: Full-featured for growing businesses
4. **Enterprise**: Custom pricing with unlimited everything
### Feature Assignment Tips
- Start with fewer features in lower tiers
- Ensure each upgrade tier adds meaningful value
- Keep support features as upgrade incentives
- API access typically belongs in Business+ tiers
### Stripe Integration
For each tier, you can optionally configure:
- **Stripe Product ID**: Link to Stripe product
- **Stripe Monthly Price ID**: Link to monthly price
- **Stripe Annual Price ID**: Link to annual price
These are required for automated billing via Stripe Checkout.
## Related Documentation
- [Subscription & Billing System](../features/subscription-billing.md) - Complete billing documentation
- [Feature Gating System](../implementation/feature-gating-system.md) - Technical feature gating details

View File

@@ -338,6 +338,65 @@ Located at `/admin/features`:
- Search by name/code
- View tier requirements
## Admin Tier Management UI
Located at `/admin/subscription-tiers`:
### Overview
The subscription tiers admin page provides full CRUD functionality for managing subscription tiers and their feature assignments.
### Features
1. **Stats Cards**: Display total tiers, active tiers, public tiers, and estimated MRR
2. **Tier Table**: Sortable list of all tiers with:
- Display order
- Code (colored badge by tier)
- Name
- Monthly/Annual pricing
- Limits (orders, products, team members)
- Feature count
- Status (Active/Private/Inactive)
- Actions (Edit Features, Edit, Activate/Deactivate)
3. **Create/Edit Modal**: Form with all tier fields:
- Code and Name
- Monthly and Annual pricing (in cents)
- Order, Product, and Team member limits
- Display order
- Stripe IDs (optional)
- Description
- Active/Public toggles
4. **Feature Assignment Slide-over Panel**:
- Opens when clicking the puzzle-piece icon
- Shows all features grouped by category
- Checkbox selection with Select all/Deselect all per category
- Feature count in footer
- Save to update tier's feature assignments
### Files
| File | Purpose |
|------|---------|
| `app/templates/admin/subscription-tiers.html` | Page template |
| `static/admin/js/subscription-tiers.js` | Alpine.js component |
| `app/routes/admin_pages.py` | Route registration |
### API Endpoints Used
| Action | Method | Endpoint |
|--------|--------|----------|
| Load tiers | GET | `/api/v1/admin/subscriptions/tiers` |
| Load stats | GET | `/api/v1/admin/subscriptions/stats` |
| Create tier | POST | `/api/v1/admin/subscriptions/tiers` |
| Update tier | PATCH | `/api/v1/admin/subscriptions/tiers/{code}` |
| Delete tier | DELETE | `/api/v1/admin/subscriptions/tiers/{code}` |
| Load features | GET | `/api/v1/admin/features` |
| Load categories | GET | `/api/v1/admin/features/categories` |
| Get tier features | GET | `/api/v1/admin/features/tiers/{code}/features` |
| Update tier features | PUT | `/api/v1/admin/features/tiers/{code}/features` |
## Migration
The features are seeded via Alembic migration:

View File

@@ -211,6 +211,7 @@ nav:
- User Management: guides/user-management.md
- Product Management: guides/product-management.md
- Inventory Management: guides/inventory-management.md
- Subscription Tier Management: guides/subscription-tier-management.md
- Shop Setup: guides/shop-setup.md
- CSV Import: guides/csv-import.md
- Marketplace Integration: guides/marketplace-integration.md

View File

@@ -1,7 +1,7 @@
// static/admin/js/subscription-tiers.js
// noqa: JS-003 - Uses ...baseData which is data() with safety check
const tiersLog = window.LogConfig?.loggers?.subscriptionTiers || console;
const tiersLog = window.LogConfig?.loggers?.subscriptionTiers || window.LogConfig?.createLogger?.('subscriptionTiers') || console;
function adminSubscriptionTiers() {
// Get base data with safety check for standalone usage
@@ -23,6 +23,16 @@ function adminSubscriptionTiers() {
stats: null,
includeInactive: false,
// Feature management
features: [],
categories: [],
featuresGrouped: {},
selectedFeatures: [],
selectedTierForFeatures: null,
showFeaturePanel: false,
loadingFeatures: false,
savingFeatures: false,
// Sorting
sortBy: 'display_order',
sortOrder: 'asc',
@@ -56,8 +66,18 @@ function adminSubscriptionTiers() {
window._adminSubscriptionTiersInitialized = true;
tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZING ===');
await this.loadTiers();
await this.loadStats();
try {
await Promise.all([
this.loadTiers(),
this.loadStats(),
this.loadFeatures(),
this.loadCategories()
]);
tiersLog.info('=== SUBSCRIPTION TIERS PAGE INITIALIZED ===');
} catch (error) {
tiersLog.error('Failed to initialize subscription tiers page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
@@ -207,6 +227,129 @@ function adminSubscriptionTiers() {
style: 'currency',
currency: 'EUR'
}).format(cents / 100);
},
// ==================== FEATURE MANAGEMENT ====================
async loadFeatures() {
try {
const data = await apiClient.get('/admin/features');
this.features = data.features || [];
tiersLog.info(`Loaded ${this.features.length} features`);
} catch (error) {
tiersLog.error('Failed to load features:', error);
}
},
async loadCategories() {
try {
const data = await apiClient.get('/admin/features/categories');
this.categories = data.categories || [];
tiersLog.info(`Loaded ${this.categories.length} categories`);
} catch (error) {
tiersLog.error('Failed to load categories:', error);
}
},
groupFeaturesByCategory() {
this.featuresGrouped = {};
for (const category of this.categories) {
this.featuresGrouped[category] = this.features.filter(f => f.category === category);
}
},
async openFeaturePanel(tier) {
tiersLog.info('Opening feature panel for tier:', tier.code);
this.selectedTierForFeatures = tier;
this.loadingFeatures = true;
this.showFeaturePanel = true;
try {
// Load tier's current features
const data = await apiClient.get(`/admin/features/tiers/${tier.code}/features`);
if (data.features) {
this.selectedFeatures = data.features.map(f => f.code);
} else {
this.selectedFeatures = tier.features || [];
}
} catch (error) {
tiersLog.error('Failed to load tier features:', error);
this.selectedFeatures = tier.features || [];
} finally {
this.groupFeaturesByCategory();
this.loadingFeatures = false;
}
},
closeFeaturePanel() {
this.showFeaturePanel = false;
this.selectedTierForFeatures = null;
this.selectedFeatures = [];
this.featuresGrouped = {};
},
toggleFeature(featureCode) {
const index = this.selectedFeatures.indexOf(featureCode);
if (index === -1) {
this.selectedFeatures.push(featureCode);
} else {
this.selectedFeatures.splice(index, 1);
}
},
isFeatureSelected(featureCode) {
return this.selectedFeatures.includes(featureCode);
},
async saveFeatures() {
if (!this.selectedTierForFeatures) return;
tiersLog.info('Saving features for tier:', this.selectedTierForFeatures.code);
this.savingFeatures = true;
try {
await apiClient.put(
`/admin/features/tiers/${this.selectedTierForFeatures.code}/features`,
{ feature_codes: this.selectedFeatures }
);
this.successMessage = `Features updated for ${this.selectedTierForFeatures.name}`;
this.closeFeaturePanel();
await this.loadTiers();
} catch (error) {
tiersLog.error('Failed to save features:', error);
this.error = error.message || 'Failed to save features';
} finally {
this.savingFeatures = false;
}
},
selectAllInCategory(category) {
const categoryFeatures = this.featuresGrouped[category] || [];
for (const feature of categoryFeatures) {
if (!this.selectedFeatures.includes(feature.code)) {
this.selectedFeatures.push(feature.code);
}
}
},
deselectAllInCategory(category) {
const categoryFeatures = this.featuresGrouped[category] || [];
const codes = categoryFeatures.map(f => f.code);
this.selectedFeatures = this.selectedFeatures.filter(c => !codes.includes(c));
},
allSelectedInCategory(category) {
const categoryFeatures = this.featuresGrouped[category] || [];
if (categoryFeatures.length === 0) return false;
return categoryFeatures.every(f => this.selectedFeatures.includes(f.code));
},
formatCategoryName(category) {
return category
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
};
}