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>
225 lines
7.4 KiB
JavaScript
225 lines
7.4 KiB
JavaScript
// app/modules/billing/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 store
|
|
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 store code from URL
|
|
const storeCode = this.getStoreCode();
|
|
if (!storeCode) {
|
|
log.warn('[FeatureStore] No store 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('/store/billing/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 storeCode = this.getStoreCode();
|
|
if (!storeCode) return;
|
|
|
|
try {
|
|
const response = await apiClient.get('/store/billing/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 store 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 store 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 store 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 store code from server-rendered value or URL fallback
|
|
* @returns {string|null}
|
|
*/
|
|
getStoreCode() {
|
|
if (window.STORE_CODE) return window.STORE_CODE;
|
|
const path = window.location.pathname;
|
|
const segments = path.split('/').filter(Boolean);
|
|
// Direct: /store/{code}/...
|
|
if (segments[0] === 'store' && segments[1]) {
|
|
return segments[1];
|
|
}
|
|
// Platform-prefixed: /platforms/{platform}/store/{code}/...
|
|
const storeIdx = segments.indexOf('store');
|
|
if (storeIdx !== -1 && segments[storeIdx + 1]) {
|
|
return segments[storeIdx + 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;
|
|
|
|
})();
|