refactor(js): migrate JavaScript files to module directories

Move 47 JS files from static/{admin,vendor,shared}/js/ to their
respective module directories app/modules/*/static/*/js/:

- Orders: orders.js, order-detail.js
- Catalog: products.js (renamed from vendor-products.js), product-*.js
- Inventory: inventory.js (admin & vendor)
- Customers: customers.js, users.js, user-*.js
- Billing: billing-history.js, subscriptions.js, subscription-tiers.js,
  billing.js, invoices.js, feature-store.js, upgrade-prompts.js
- Messaging: messages.js, notifications.js, email-templates.js
- Marketplace: marketplace*.js, letzshop*.js, onboarding.js
- Monitoring: monitoring.js, background-tasks.js, imports.js, logs.js
- Dev Tools: testing-*.js, code-quality-*.js

Update 39 templates to reference new module static paths using
url_for('{module}_static', path='...') pattern.

Files staying in static/ (platform core):
- admin: dashboard, login, platforms, vendors, companies, admin-users,
  settings, components, init-alpine, module-config
- vendor: dashboard, login, profile, settings, team, media, init-alpine
- shared: api-client, utils, money, icons, log-config, vendor-selector,
  media-picker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 22:08:20 +01:00
parent 434db1560a
commit 0b4291d893
86 changed files with 63 additions and 63 deletions

View File

@@ -1,217 +0,0 @@
// 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() {
// Guard against multiple initialization
if (window._featureStoreInitialized) return;
window._featureStoreInitialized = true;
try {
log.debug('[FeatureStore] Initializing...');
await this.loadFeatures();
} catch (error) {
log.error('[FeatureStore] Failed to initialize:', error);
}
},
/**
* 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 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 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() {
try {
this.loaded = false;
this.features = [];
this.featuresMap = {};
await this.loadFeatures();
} catch (error) {
log.error('[FeatureStore] Failed to reload:', error);
}
}
};
// 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;
})();

View File

@@ -1,365 +0,0 @@
// 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 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 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() {
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;
})();