Some checks failed
Double-mount store routes at /store/* and /store/{store_code}/* so the
same handlers work in dev path-based, prod path-based, prod subdomain,
and prod custom-domain modes. Wire StorePlatform.custom_subdomain into
StoreContextMiddleware for per-platform subdomain overrides. Add admin
custom-domain management UI, fix stale /shop/ reset link, add
/merchants/ to reserved paths, and server-render window.STORE_CODE for
JS that previously parsed the URL.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
// app/modules/billing/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,
|
|
|
|
// Limit reached modal state
|
|
showLimitReachedConfirm: false,
|
|
limitReachedMessage: '',
|
|
limitReachedUpgradeUrl: 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 apiClient.get('/store/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 store code from server-rendered value or URL fallback
|
|
*/
|
|
getStoreCode() {
|
|
if (window.STORE_CODE) return window.STORE_CODE;
|
|
const path = window.location.pathname;
|
|
const segments = path.split('/').filter(Boolean);
|
|
if (segments[0] === 'store' && segments[1]) {
|
|
return segments[1];
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Get billing URL
|
|
*/
|
|
getBillingUrl() {
|
|
const storeCode = this.getStoreCode();
|
|
return storeCode ? `/store/${storeCode}/billing` : '#';
|
|
},
|
|
|
|
/**
|
|
* Check limit before action, show modal if at limit
|
|
*/
|
|
async checkLimitAndProceed(limitType, onSuccess) {
|
|
try {
|
|
const response = await apiClient.get(`/store/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.`;
|
|
|
|
const details = `${message} Current usage: ${response.current}/${response.limit}. ` +
|
|
(response.upgrade_tier_name
|
|
? `Upgrade to ${response.upgrade_tier_name} to get more ${limitName}.`
|
|
: 'Contact support for more capacity.');
|
|
|
|
this.limitReachedMessage = details;
|
|
this.limitReachedUpgradeUrl = response.upgrade_tier_code ? this.getBillingUrl() : null;
|
|
this.showLimitReachedConfirm = true;
|
|
},
|
|
|
|
/**
|
|
* Handle upgrade confirmation from limit reached modal
|
|
*/
|
|
confirmUpgrade() {
|
|
if (this.limitReachedUpgradeUrl) {
|
|
window.location.href = this.limitReachedUpgradeUrl;
|
|
}
|
|
this.showLimitReachedConfirm = false;
|
|
},
|
|
|
|
/**
|
|
* 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() {
|
|
try {
|
|
this.loaded = false;
|
|
await this.loadUsage();
|
|
} catch (error) {
|
|
log.error('[UpgradePrompts] Failed to reload:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
|
|
})();
|