feat: add comprehensive tier-based feature management system
Implement database-driven feature gating with contextual upgrade prompts: - Add Feature model with 30 features across 8 categories - Create FeatureService with caching for tier-based feature checking - Add @require_feature decorator and RequireFeature dependency for backend enforcement - Create vendor features API (6 endpoints) and admin features API - Add Alpine.js feature store and upgrade prompts store for frontend - Create Jinja macros: feature_gate, feature_locked, limit_warning, usage_bar - Add usage API for tracking orders/products/team limits with upgrade info - Fix Stripe webhook to create VendorAddOn records on addon purchase - Integrate upgrade prompts into vendor dashboard with tier badge and usage bars 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
205
static/shared/js/feature-store.js
Normal file
205
static/shared/js/feature-store.js
Normal file
@@ -0,0 +1,205 @@
|
||||
// static/shared/js/feature-store.js
|
||||
/**
|
||||
* Feature Store for Alpine.js
|
||||
*
|
||||
* Provides feature availability checking for tier-based access control.
|
||||
* Loads features from the API on init and caches them for the session.
|
||||
*
|
||||
* Usage in templates:
|
||||
*
|
||||
* 1. Check if feature is available:
|
||||
* <div x-show="$store.features.has('analytics_dashboard')">
|
||||
* Analytics content here
|
||||
* </div>
|
||||
*
|
||||
* 2. Show upgrade prompt if not available:
|
||||
* <div x-show="!$store.features.has('analytics_dashboard')">
|
||||
* <p>Upgrade to access Analytics</p>
|
||||
* </div>
|
||||
*
|
||||
* 3. Conditionally render with x-if:
|
||||
* <template x-if="$store.features.has('api_access')">
|
||||
* <a href="/settings/api">API Settings</a>
|
||||
* </template>
|
||||
*
|
||||
* 4. Use feature data for upgrade prompts:
|
||||
* <p x-text="$store.features.getUpgradeTier('analytics_dashboard')"></p>
|
||||
*
|
||||
* 5. Get current tier info:
|
||||
* <span x-text="$store.features.tierName"></span>
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Use centralized logger if available
|
||||
const log = window.LogConfig?.log || console;
|
||||
|
||||
/**
|
||||
* Feature Store
|
||||
*/
|
||||
const featureStore = {
|
||||
// State
|
||||
features: [], // Array of feature codes available to vendor
|
||||
featuresMap: {}, // Full feature info keyed by code
|
||||
tierCode: null, // Current tier code
|
||||
tierName: null, // Current tier name
|
||||
loading: true, // Loading state
|
||||
loaded: false, // Whether features have been loaded
|
||||
error: null, // Error message if load failed
|
||||
|
||||
/**
|
||||
* Initialize the feature store
|
||||
* Called automatically when Alpine starts
|
||||
*/
|
||||
async init() {
|
||||
log.debug('[FeatureStore] Initializing...');
|
||||
await this.loadFeatures();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load features from API
|
||||
*/
|
||||
async loadFeatures() {
|
||||
// Don't reload if already loaded
|
||||
if (this.loaded) {
|
||||
log.debug('[FeatureStore] Already loaded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get vendor code from URL
|
||||
const vendorCode = this.getVendorCode();
|
||||
if (!vendorCode) {
|
||||
log.warn('[FeatureStore] No vendor code found in URL');
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// Fetch available features (lightweight endpoint)
|
||||
const response = await window.apiClient.get('/vendor/features/available');
|
||||
|
||||
this.features = response.features || [];
|
||||
this.tierCode = response.tier_code;
|
||||
this.tierName = response.tier_name;
|
||||
this.loaded = true;
|
||||
|
||||
log.debug(`[FeatureStore] Loaded ${this.features.length} features for ${this.tierName} tier`);
|
||||
|
||||
} catch (error) {
|
||||
log.error('[FeatureStore] Failed to load features:', error);
|
||||
this.error = error.message || 'Failed to load features';
|
||||
// Set empty array so checks don't fail
|
||||
this.features = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load full feature details (with metadata)
|
||||
* Use this when you need upgrade info
|
||||
*/
|
||||
async loadFullFeatures() {
|
||||
const vendorCode = this.getVendorCode();
|
||||
if (!vendorCode) return;
|
||||
|
||||
try {
|
||||
const response = await window.apiClient.get('/vendor/features');
|
||||
|
||||
// Build map for quick lookup
|
||||
this.featuresMap = {};
|
||||
for (const feature of response.features) {
|
||||
this.featuresMap[feature.code] = feature;
|
||||
}
|
||||
|
||||
log.debug(`[FeatureStore] Loaded full details for ${response.features.length} features`);
|
||||
|
||||
} catch (error) {
|
||||
log.error('[FeatureStore] Failed to load full features:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if vendor has access to a feature
|
||||
* @param {string} featureCode - The feature code to check
|
||||
* @returns {boolean} - Whether the feature is available
|
||||
*/
|
||||
has(featureCode) {
|
||||
return this.features.includes(featureCode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if vendor has access to ANY of the given features
|
||||
* @param {...string} featureCodes - Feature codes to check
|
||||
* @returns {boolean} - Whether any feature is available
|
||||
*/
|
||||
hasAny(...featureCodes) {
|
||||
return featureCodes.some(code => this.has(code));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if vendor has access to ALL of the given features
|
||||
* @param {...string} featureCodes - Feature codes to check
|
||||
* @returns {boolean} - Whether all features are available
|
||||
*/
|
||||
hasAll(...featureCodes) {
|
||||
return featureCodes.every(code => this.has(code));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get feature info (requires loadFullFeatures first)
|
||||
* @param {string} featureCode - The feature code
|
||||
* @returns {object|null} - Feature info or null
|
||||
*/
|
||||
getFeature(featureCode) {
|
||||
return this.featuresMap[featureCode] || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the tier name required for a feature
|
||||
* @param {string} featureCode - The feature code
|
||||
* @returns {string|null} - Tier name or null
|
||||
*/
|
||||
getUpgradeTier(featureCode) {
|
||||
const feature = this.getFeature(featureCode);
|
||||
return feature?.minimum_tier_name || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get vendor code from URL
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getVendorCode() {
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments[0] === 'vendor' && segments[1]) {
|
||||
return segments[1];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reload features (e.g., after tier change)
|
||||
*/
|
||||
async reload() {
|
||||
this.loaded = false;
|
||||
this.features = [];
|
||||
this.featuresMap = {};
|
||||
await this.loadFeatures();
|
||||
}
|
||||
};
|
||||
|
||||
// Register Alpine store when Alpine is available
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('features', featureStore);
|
||||
log.debug('[FeatureStore] Registered as Alpine store');
|
||||
});
|
||||
|
||||
// Also expose globally for non-Alpine usage
|
||||
window.FeatureStore = featureStore;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user