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;
|
||||
|
||||
})();
|
||||
361
static/shared/js/upgrade-prompts.js
Normal file
361
static/shared/js/upgrade-prompts.js
Normal file
@@ -0,0 +1,361 @@
|
||||
// static/shared/js/upgrade-prompts.js
|
||||
/**
|
||||
* Upgrade Prompts System
|
||||
*
|
||||
* Provides contextual upgrade prompts based on:
|
||||
* - Usage limits approaching/reached
|
||||
* - Locked features
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* 1. Initialize the store (auto-loads usage on init):
|
||||
* <div x-data x-init="$store.upgrade.loadUsage()">
|
||||
*
|
||||
* 2. Show limit warning banner:
|
||||
* <template x-if="$store.upgrade.shouldShowLimitWarning('orders')">
|
||||
* <div x-html="$store.upgrade.getLimitWarningHTML('orders')"></div>
|
||||
* </template>
|
||||
*
|
||||
* 3. Check before action:
|
||||
* <button @click="$store.upgrade.checkLimitAndProceed('products', () => createProduct())">
|
||||
* Add Product
|
||||
* </button>
|
||||
*
|
||||
* 4. Show upgrade CTA on dashboard:
|
||||
* <template x-if="$store.upgrade.hasUpgradeRecommendation">
|
||||
* <div x-html="$store.upgrade.getUpgradeCardHTML()"></div>
|
||||
* </template>
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const log = window.LogConfig?.log || console;
|
||||
|
||||
/**
|
||||
* Upgrade Prompts Store
|
||||
*/
|
||||
const upgradeStore = {
|
||||
// State
|
||||
usage: null,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
error: null,
|
||||
|
||||
// Computed-like getters
|
||||
get hasLimitsApproaching() {
|
||||
return this.usage?.has_limits_approaching || false;
|
||||
},
|
||||
|
||||
get hasLimitsReached() {
|
||||
return this.usage?.has_limits_reached || false;
|
||||
},
|
||||
|
||||
get hasUpgradeRecommendation() {
|
||||
return this.usage?.upgrade_available && (this.hasLimitsApproaching || this.hasLimitsReached);
|
||||
},
|
||||
|
||||
get upgradeReasons() {
|
||||
return this.usage?.upgrade_reasons || [];
|
||||
},
|
||||
|
||||
get currentTier() {
|
||||
return this.usage?.tier || null;
|
||||
},
|
||||
|
||||
get nextTier() {
|
||||
return this.usage?.upgrade_tier || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load usage data from API
|
||||
*/
|
||||
async loadUsage() {
|
||||
if (this.loaded || this.loading) return;
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await window.apiClient.get('/vendor/usage');
|
||||
this.usage = response;
|
||||
this.loaded = true;
|
||||
|
||||
log.debug('[UpgradePrompts] Loaded usage data', this.usage);
|
||||
|
||||
} catch (error) {
|
||||
log.error('[UpgradePrompts] Failed to load usage:', error);
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get usage metric by name
|
||||
*/
|
||||
getMetric(name) {
|
||||
if (!this.usage?.usage) return null;
|
||||
return this.usage.usage.find(m => m.name === name);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if should show limit warning for a metric
|
||||
*/
|
||||
shouldShowLimitWarning(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
return metric && (metric.is_approaching_limit || metric.is_at_limit);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if at limit for a metric
|
||||
*/
|
||||
isAtLimit(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
return metric?.is_at_limit || false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get percentage used for a metric
|
||||
*/
|
||||
getPercentage(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
return metric?.percentage || 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get formatted usage string (e.g., "85/100")
|
||||
*/
|
||||
getUsageString(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
if (!metric) return '';
|
||||
if (metric.is_unlimited) return `${metric.current} (unlimited)`;
|
||||
return `${metric.current}/${metric.limit}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get vendor code from URL
|
||||
*/
|
||||
getVendorCode() {
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments[0] === 'vendor' && segments[1]) {
|
||||
return segments[1];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get billing URL
|
||||
*/
|
||||
getBillingUrl() {
|
||||
const vendorCode = this.getVendorCode();
|
||||
return vendorCode ? `/vendor/${vendorCode}/billing` : '#';
|
||||
},
|
||||
|
||||
/**
|
||||
* Check limit before action, show modal if at limit
|
||||
*/
|
||||
async checkLimitAndProceed(limitType, onSuccess) {
|
||||
try {
|
||||
const response = await window.apiClient.get(`/vendor/usage/check/${limitType}`);
|
||||
|
||||
if (response.can_proceed) {
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Show upgrade modal
|
||||
this.showLimitReachedModal(limitType, response);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[UpgradePrompts] Failed to check limit:', error);
|
||||
// Proceed anyway on error (fail open)
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show limit reached modal
|
||||
*/
|
||||
showLimitReachedModal(limitType, response) {
|
||||
const limitNames = {
|
||||
'orders': 'monthly orders',
|
||||
'products': 'products',
|
||||
'team_members': 'team members'
|
||||
};
|
||||
|
||||
const limitName = limitNames[limitType] || limitType;
|
||||
const message = response.message || `You've reached your ${limitName} limit.`;
|
||||
|
||||
// Use browser confirm for simplicity - could be replaced with custom modal
|
||||
const shouldUpgrade = confirm(
|
||||
`${message}\n\n` +
|
||||
`Current: ${response.current}/${response.limit}\n\n` +
|
||||
(response.upgrade_tier_name
|
||||
? `Upgrade to ${response.upgrade_tier_name} to get more ${limitName}.\n\nGo to billing page?`
|
||||
: 'Contact support for more capacity.')
|
||||
);
|
||||
|
||||
if (shouldUpgrade && response.upgrade_tier_code) {
|
||||
window.location.href = this.getBillingUrl();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get limit warning banner HTML
|
||||
*/
|
||||
getLimitWarningHTML(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
if (!metric) return '';
|
||||
|
||||
const names = {
|
||||
'orders': 'monthly orders',
|
||||
'products': 'products',
|
||||
'team_members': 'team members'
|
||||
};
|
||||
const name = names[metricName] || metricName;
|
||||
|
||||
if (metric.is_at_limit) {
|
||||
return `
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
You've reached your ${name} limit (${metric.current}/${metric.limit})
|
||||
</p>
|
||||
</div>
|
||||
<a href="${this.getBillingUrl()}"
|
||||
class="ml-4 px-3 py-1 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded">
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (metric.is_approaching_limit) {
|
||||
return `
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-yellow-500 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
You're approaching your ${name} limit (${metric.current}/${metric.limit} - ${Math.round(metric.percentage)}%)
|
||||
</p>
|
||||
</div>
|
||||
<a href="${this.getBillingUrl()}"
|
||||
class="ml-4 px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-200 hover:bg-yellow-300 dark:bg-yellow-800 dark:text-yellow-200 dark:hover:bg-yellow-700 rounded">
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get upgrade card HTML for dashboard
|
||||
*/
|
||||
getUpgradeCardHTML() {
|
||||
if (!this.usage?.upgrade_tier) return '';
|
||||
|
||||
const tier = this.usage.upgrade_tier;
|
||||
const reasons = this.usage.upgrade_reasons || [];
|
||||
|
||||
return `
|
||||
<div class="bg-gradient-to-r from-purple-500 to-indigo-600 rounded-lg p-6 text-white">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">Upgrade to ${tier.name}</h3>
|
||||
${reasons.length > 0 ? `
|
||||
<ul class="text-sm opacity-90 mb-4 space-y-1">
|
||||
${reasons.map(r => `<li>• ${r}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
${tier.benefits.length > 0 ? `
|
||||
<p class="text-sm opacity-80 mb-2">Get access to:</p>
|
||||
<ul class="text-sm space-y-1 mb-4">
|
||||
${tier.benefits.slice(0, 4).map(b => `
|
||||
<li class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
${b}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-bold">€${(tier.price_monthly_cents / 100).toFixed(0)}</p>
|
||||
<p class="text-sm opacity-80">/month</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="${this.getBillingUrl()}"
|
||||
class="mt-4 inline-flex items-center px-4 py-2 bg-white text-purple-600 font-medium rounded-lg hover:bg-gray-100 transition-colors">
|
||||
Upgrade Now
|
||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get compact usage bar HTML
|
||||
*/
|
||||
getUsageBarHTML(metricName) {
|
||||
const metric = this.getMetric(metricName);
|
||||
if (!metric || metric.is_unlimited) return '';
|
||||
|
||||
const percentage = Math.min(metric.percentage, 100);
|
||||
const colorClass = metric.is_at_limit
|
||||
? 'bg-red-500'
|
||||
: metric.is_approaching_limit
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500';
|
||||
|
||||
return `
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
<span>${metric.current} / ${metric.limit}</span>
|
||||
<span>${Math.round(percentage)}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="${colorClass} h-2 rounded-full transition-all" style="width: ${percentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reload usage data
|
||||
*/
|
||||
async reload() {
|
||||
this.loaded = false;
|
||||
await this.loadUsage();
|
||||
}
|
||||
};
|
||||
|
||||
// Register Alpine store when Alpine is available
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('upgrade', upgradeStore);
|
||||
log.debug('[UpgradePrompts] Registered as Alpine store');
|
||||
});
|
||||
|
||||
// Also expose globally
|
||||
window.UpgradePrompts = upgradeStore;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user