diff --git a/app/modules/catalog/templates/catalog/admin/store-products.html b/app/modules/catalog/templates/catalog/admin/store-products.html index 69d2c9a4..b027fbf2 100644 --- a/app/modules/catalog/templates/catalog/admin/store-products.html +++ b/app/modules/catalog/templates/catalog/admin/store-products.html @@ -5,62 +5,18 @@ {% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/tables.html' import table_wrapper %} {% from 'shared/macros/modals.html' import modal_simple %} +{% from 'shared/macros/inputs.html' import entity_selector, entity_selected_badge %} {% block title %}Store Products{% endblock %} {% block alpine_data %}adminStoreProducts(){% endblock %} -{% block extra_head %} - - - -{% endblock %} - {% block content %} {% call page_header_flex(title='Store Products', subtitle='Browse store-specific product catalogs with override capability') %}
-
- -
+ {{ entity_selector(ref_name='storeSelect', id='store-select', placeholder='Filter by store...') }} {{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }} -
-
-
-
- -
-
- - -
-
- -
-
+{{ entity_selected_badge(entity_var='selectedStore', clear_fn='clearStoreFilter()', code_field='store_code', color='purple') }} {{ loading_state('Loading products...') }} diff --git a/app/modules/core/static/shared/js/entity-selector.js b/app/modules/core/static/shared/js/entity-selector.js new file mode 100644 index 00000000..1419995f --- /dev/null +++ b/app/modules/core/static/shared/js/entity-selector.js @@ -0,0 +1,262 @@ +// static/shared/js/entity-selector.js +/** + * Shared Entity Selector Module + * ============================== + * A configurable Tom Select-based autocomplete for any entity type. + * Generalizes store-selector.js to work with stores, merchants, platforms, etc. + * + * Usage: + * const selector = initEntitySelector(this.$refs.merchantSelect, { + * apiEndpoint: '/admin/merchants', + * responseKey: 'merchants', + * searchFields: ['name'], + * valueField: 'id', + * labelField: 'name', + * codeField: null, + * onSelect: (entity) => this.onMerchantSelected(entity), + * onClear: () => this.onMerchantCleared() + * }); + * + * selector.setValue(merchantId, merchantData); + * selector.clear(); + */ + +const entitySelectorLog = window.LogConfig?.loggers?.entitySelector || + window.LogConfig?.createLogger?.('entitySelector', false) || + { info: console.log, warn: console.warn, error: console.error }; // noqa: js-001 - fallback if logger not ready + +/** + * Wait for Tom Select to be available + */ +function waitForTomSelectReady(callback, maxRetries = 20, retryDelay = 100) { + let retries = 0; + + function check() { + if (typeof TomSelect !== 'undefined') { + callback(); + } else if (retries < maxRetries) { + retries++; + setTimeout(check, retryDelay); + } else { + entitySelectorLog.error('TomSelect not available after maximum retries'); + } + } + + check(); +} + +/** + * Initialize an entity selector on the given element + * @param {HTMLElement} selectElement - The select element to enhance + * @param {Object} options - Configuration options + * @param {string} options.apiEndpoint - API endpoint for search (e.g. '/admin/merchants') + * @param {string} options.responseKey - Key in API response containing the array (e.g. 'merchants') + * @param {string} options.valueField - Field used as option value (default: 'id') + * @param {string} options.labelField - Field used as display label (default: 'name') + * @param {string[]} options.searchFields - Fields to search on (default: ['name']) + * @param {string|null} options.codeField - Optional secondary code field for display (e.g. 'store_code') + * @param {Function} options.onSelect - Callback when entity is selected (receives entity object) + * @param {Function} options.onClear - Callback when selection is cleared + * @param {number} options.minChars - Minimum characters before search (default: 2) + * @param {number} options.maxOptions - Maximum options to show (default: 50) + * @param {string} options.placeholder - Placeholder text + * @param {string} options.noResultsText - Text when no results (default: 'No results found') + * @returns {Object} Controller object with setValue(), clear(), getInstance(), destroy() + */ +function initEntitySelector(selectElement, options = {}) { + if (!selectElement) { + entitySelectorLog.error('Entity selector element not provided'); + return null; + } + + const config = { + apiEndpoint: options.apiEndpoint || '/admin/stores', + responseKey: options.responseKey || 'stores', + valueField: options.valueField || 'id', + labelField: options.labelField || 'name', + searchFields: options.searchFields || ['name'], + codeField: options.codeField || null, + minChars: options.minChars || 2, + maxOptions: options.maxOptions || 50, + placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search...', + noResultsText: options.noResultsText || 'No results found', + onSelect: options.onSelect || (() => {}), + onClear: options.onClear || (() => {}) + }; + + let tomSelectInstance = null; + + const controller = { + /** + * Set the selected entity by ID + * @param {*} entityId - Entity ID to select + * @param {Object} entityData - Optional entity data to avoid API call + */ + setValue: async function(entityId, entityData = null) { + if (!tomSelectInstance) return; + + if (entityData) { + const optionData = {}; + optionData[config.valueField] = entityData[config.valueField] || entityId; + optionData[config.labelField] = entityData[config.labelField]; + if (config.codeField) { + optionData[config.codeField] = entityData[config.codeField]; + } + tomSelectInstance.addOption(optionData); + tomSelectInstance.setValue(optionData[config.valueField], true); + } else if (entityId) { + try { + const response = await apiClient.get(`${config.apiEndpoint}/${entityId}`); + const optionData = {}; + optionData[config.valueField] = response[config.valueField] || entityId; + optionData[config.labelField] = response[config.labelField]; + if (config.codeField) { + optionData[config.codeField] = response[config.codeField]; + } + tomSelectInstance.addOption(optionData); + tomSelectInstance.setValue(optionData[config.valueField], true); + } catch (error) { + entitySelectorLog.error('Failed to load entity:', error); + } + } + }, + + clear: function() { + if (tomSelectInstance) { + tomSelectInstance.clear(); + } + }, + + getInstance: function() { + return tomSelectInstance; + }, + + destroy: function() { + if (tomSelectInstance) { + tomSelectInstance.destroy(); + tomSelectInstance = null; + } + } + }; + + // Initialize Tom Select when available + waitForTomSelectReady(() => { + entitySelectorLog.info(`Initializing entity selector (${config.responseKey})`); + + // Build render functions + const render = { + option: function(data, escape) { + if (config.codeField && data[config.codeField]) { + return `
+ ${escape(data[config.labelField])} + ${escape(data[config.codeField])} +
`; + } + return `
${escape(data[config.labelField])}
`; + }, + item: function(data, escape) { + if (config.codeField && data[config.codeField]) { + return `
+ ${escape(data[config.labelField])} + (${escape(data[config.codeField])}) +
`; + } + return `
${escape(data[config.labelField])}
`; + }, + no_results: function() { + return `
${config.noResultsText}
`; + }, + loading: function() { + return '
Searching...
'; + } + }; + + tomSelectInstance = new TomSelect(selectElement, { + valueField: config.valueField, + labelField: config.labelField, + searchField: config.searchFields, + maxOptions: config.maxOptions, + placeholder: config.placeholder, + + load: async function(query, callback) { + if (query.length < config.minChars) { + callback([]); + return; + } + + try { + const sep = config.apiEndpoint.includes('?') ? '&' : '?'; + const response = await apiClient.get( + `${config.apiEndpoint}${sep}search=${encodeURIComponent(query)}&limit=${config.maxOptions}` + ); + + const items = (response[config.responseKey] || []).map(v => { + const item = {}; + item[config.valueField] = v[config.valueField]; + item[config.labelField] = v[config.labelField]; + if (config.codeField) { + item[config.codeField] = v[config.codeField]; + } + return item; + }); + + entitySelectorLog.info(`Found ${items.length} ${config.responseKey} for "${query}"`); + callback(items); + } catch (error) { + entitySelectorLog.error(`${config.responseKey} search failed:`, error); + callback([]); + } + }, + + render: render, + + onChange: function(value) { + if (value) { + const selectedOption = this.options[value]; + if (selectedOption) { + entitySelectorLog.info(`${config.responseKey} selected:`, selectedOption); + const entity = {}; + entity[config.valueField] = config.valueField === 'id' ? parseInt(value) : value; + entity[config.labelField] = selectedOption[config.labelField]; + if (config.codeField) { + entity[config.codeField] = selectedOption[config.codeField]; + } + config.onSelect(entity); + } + } else { + entitySelectorLog.info(`${config.responseKey} selection cleared`); + config.onClear(); + } + }, + + loadThrottle: 150, + closeAfterSelect: true, + hideSelected: false, + persist: true, + createOnBlur: false, + create: false + }); + + entitySelectorLog.info(`Entity selector (${config.responseKey}) initialized`); + }); + + return controller; +} + +// Convenience wrapper for store selector (backward compatible) +function initStoreSelectorFromEntity(selectElement, options = {}) { + return initEntitySelector(selectElement, { + apiEndpoint: options.apiEndpoint || '/admin/stores', + responseKey: 'stores', + valueField: 'id', + labelField: 'name', + searchFields: ['name', 'store_code'], + codeField: 'store_code', + placeholder: options.placeholder || 'Search store by name or code...', + noResultsText: 'No stores found', + ...options + }); +} + +// Export to window +window.initEntitySelector = initEntitySelector; diff --git a/app/modules/dev_tools/templates/dev_tools/admin/test-stores-users-migration.html b/app/modules/dev_tools/templates/dev_tools/admin/test-stores-users-migration.html index 722b804f..cc6a4b73 100644 --- a/app/modules/dev_tools/templates/dev_tools/admin/test-stores-users-migration.html +++ b/app/modules/dev_tools/templates/dev_tools/admin/test-stores-users-migration.html @@ -21,7 +21,7 @@
- +

Stores List

@@ -53,7 +53,7 @@

Quick Actions

diff --git a/app/modules/marketplace/templates/marketplace/admin/letzshop-store-directory.html b/app/modules/marketplace/templates/marketplace/admin/letzshop-store-directory.html index c32acadc..11caa0c4 100644 --- a/app/modules/marketplace/templates/marketplace/admin/letzshop-store-directory.html +++ b/app/modules/marketplace/templates/marketplace/admin/letzshop-store-directory.html @@ -44,7 +44,7 @@

- +
@@ -147,7 +147,7 @@
- +

No stores found

Click "Sync from Letzshop" to import stores. diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index 9d57310e..d33ae55a 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -164,20 +164,12 @@ tenancy_module = ModuleDefinition( order=20, is_mandatory=True, ), - ], - ), - MenuSectionDefinition( - id="contentMgmt", - label_key="tenancy.menu.content_management", - icon="globe-alt", - order=70, - items=[ MenuItemDefinition( id="platforms", label_key="tenancy.menu.platforms", icon="globe-alt", route="/admin/platforms", - order=10, + order=5, ), ], ), diff --git a/app/modules/tenancy/routes/api/admin_platforms.py b/app/modules/tenancy/routes/api/admin_platforms.py index cec9a1ab..d50effdb 100644 --- a/app/modules/tenancy/routes/api/admin_platforms.py +++ b/app/modules/tenancy/routes/api/admin_platforms.py @@ -91,6 +91,21 @@ class PlatformUpdateRequest(BaseModel): settings: dict[str, Any] | None = None +class PlatformCreateRequest(BaseModel): + """Request schema for creating a platform.""" + + code: str = Field(..., min_length=2, max_length=50, pattern=r"^[a-z][a-z0-9_-]*$") + name: str = Field(..., min_length=1, max_length=100) + description: str | None = None + description_translations: dict[str, str] | None = None + domain: str | None = None + path_prefix: str | None = None + default_language: str = "fr" + supported_languages: list[str] = Field(default_factory=lambda: ["fr", "de", "en", "lb"]) + is_active: bool = True + is_public: bool = True + + class PlatformStatsResponse(BaseModel): """Platform statistics response.""" @@ -162,6 +177,26 @@ async def list_platforms( return PlatformListResponse(platforms=result, total=len(result)) +@admin_platforms_router.post("", response_model=PlatformResponse, status_code=201) +async def create_platform( + create_data: PlatformCreateRequest, + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_admin_from_cookie_or_header), +): + """ + Create a new platform. + + Creates a new platform with the provided configuration. + """ + data = create_data.model_dump() + platform = platform_service.create_platform(db, data) + db.commit() + db.refresh(platform) + + logger.info(f"[PLATFORMS] Created platform: {platform.code}") + return _build_platform_response(db, platform) + + @admin_platforms_router.get("/{code}", response_model=PlatformResponse) async def get_platform( code: str = Path(..., description="Platform code (oms, loyalty, etc.)"), diff --git a/app/modules/tenancy/routes/pages/admin.py b/app/modules/tenancy/routes/pages/admin.py index 6177b66f..8815ffca 100644 --- a/app/modules/tenancy/routes/pages/admin.py +++ b/app/modules/tenancy/routes/pages/admin.py @@ -491,6 +491,24 @@ async def admin_platforms_list( ) +@router.get( + "/platforms/create", response_class=HTMLResponse, include_in_schema=False +) +async def admin_platform_create( + request: Request, + current_user: User = Depends(require_menu_access("platforms", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """ + Render platform creation form. + Allows creating a new platform with basic settings. + """ + return templates.TemplateResponse( + "tenancy/admin/platform-create.html", + get_admin_context(request, db, current_user), + ) + + @router.get( "/platforms/{platform_code}", response_class=HTMLResponse, include_in_schema=False ) diff --git a/app/modules/tenancy/services/platform_service.py b/app/modules/tenancy/services/platform_service.py index 4114170d..ac421afa 100644 --- a/app/modules/tenancy/services/platform_service.py +++ b/app/modules/tenancy/services/platform_service.py @@ -500,6 +500,29 @@ class PlatformService: return None + @staticmethod + def create_platform(db: Session, data: dict) -> Platform: + """ + Create a new platform. + + Note: This method does NOT commit the transaction. + The caller (API endpoint) is responsible for committing. + + Args: + db: Database session + data: Dictionary of fields for the new platform + + Returns: + Created Platform object (with pending changes) + """ + platform = Platform() + for field, value in data.items(): + if hasattr(platform, field): + setattr(platform, field, value) + db.add(platform) + logger.info(f"[PLATFORMS] Created platform: {platform.code}") + return platform + @staticmethod def update_platform( db: Session, platform: Platform, update_data: dict diff --git a/app/modules/tenancy/static/admin/js/platform-create.js b/app/modules/tenancy/static/admin/js/platform-create.js new file mode 100644 index 00000000..3b1c0b17 --- /dev/null +++ b/app/modules/tenancy/static/admin/js/platform-create.js @@ -0,0 +1,134 @@ +/** + * Platform Create - Alpine.js Component + * + * Handles platform creation for multi-platform CMS. + */ + +const platformCreateLog = window.LogConfig.createLogger('PLATFORM_CREATE'); + +function platformCreate() { + return { + // Inherit base layout functionality from init-alpine.js + ...data(), + + // Page identification + currentPage: 'platform-create', + + // State + saving: false, + error: null, + + // Language editing + currentLang: 'fr', + languageNames: { + fr: 'Fran\u00e7ais', + de: 'Deutsch', + en: 'English', + lb: 'L\u00ebtzebuergesch', + }, + + // Form data + formData: { + code: '', + name: '', + description_translations: { fr: '', de: '', en: '', lb: '' }, + domain: '', + path_prefix: '', + default_language: 'fr', + supported_languages: ['fr', 'de', 'en', 'lb'], + is_active: true, + is_public: true, + }, + + errors: {}, + + // Available languages + availableLanguages: [ + { code: 'fr', name: 'French' }, + { code: 'de', name: 'German' }, + { code: 'en', name: 'English' }, + { code: 'lb', name: 'Luxembourgish' }, + ], + + // Lifecycle + async init() { + platformCreateLog.info('=== PLATFORM CREATE PAGE INITIALIZING ==='); + + // Duplicate initialization guard + if (window._platformCreateInitialized) { + platformCreateLog.warn('Platform create page already initialized, skipping...'); + return; + } + window._platformCreateInitialized = true; + + platformCreateLog.info('=== PLATFORM CREATE PAGE INITIALIZATION COMPLETE ==='); + }, + + async handleSubmit() { + this.saving = true; + this.error = null; + this.errors = {}; + + try { + // Sync base description from default language translation + const defaultLang = this.formData.default_language || 'fr'; + const baseDesc = this.formData.description_translations[defaultLang] || ''; + + const payload = { + code: this.formData.code, + name: this.formData.name, + description: baseDesc || null, + description_translations: this.formData.description_translations, + domain: this.formData.domain || null, + path_prefix: this.formData.path_prefix || null, + default_language: this.formData.default_language, + supported_languages: this.formData.supported_languages, + is_active: this.formData.is_active, + is_public: this.formData.is_public, + }; + + const response = await apiClient.post('/admin/platforms', payload); + + platformCreateLog.info(`Created platform: ${response.code}`); + + // Redirect to the new platform's detail page + window.location.href = `/admin/platforms/${response.code}`; + } catch (err) { + platformCreateLog.error('Error creating platform:', err); + this.error = err.message || 'Failed to create platform'; + + // Handle validation errors + if (err.details) { + this.errors = err.details; + } + } finally { + this.saving = false; + } + }, + + // Helper Methods + isLanguageSupported(code) { + return this.formData.supported_languages.includes(code); + }, + + toggleLanguage(code) { + const index = this.formData.supported_languages.indexOf(code); + if (index > -1) { + // Don't allow removing the last language + if (this.formData.supported_languages.length > 1) { + this.formData.supported_languages.splice(index, 1); + // Switch tab if the removed language was active + if (this.currentLang === code) { + this.currentLang = this.formData.supported_languages[0]; + } + } + } else { + this.formData.supported_languages.push(code); + // Initialize empty translation for new language + if (!this.formData.description_translations[code]) { + this.formData.description_translations[code] = ''; + } + } + }, + }; +} diff --git a/app/modules/tenancy/static/admin/js/platform-detail.js b/app/modules/tenancy/static/admin/js/platform-detail.js index 54a680ae..e92cce73 100644 --- a/app/modules/tenancy/static/admin/js/platform-detail.js +++ b/app/modules/tenancy/static/admin/js/platform-detail.js @@ -17,7 +17,6 @@ function platformDetail() { // State platform: null, stats: null, - recentPages: [], loading: true, error: null, platformCode: null, @@ -41,10 +40,7 @@ function platformDetail() { if (match) { this.platformCode = match[1]; platformDetailLog.info('Viewing platform:', this.platformCode); - await Promise.all([ - this.loadPlatform(), - this.loadRecentPages(), - ]); + await this.loadPlatform(); } else { platformDetailLog.error('No platform code in URL'); this.error = 'Platform code not found in URL'; @@ -74,19 +70,6 @@ function platformDetail() { } }, - async loadRecentPages() { - try { - // Load recent content pages for this platform - const response = await apiClient.get(`/admin/content-pages?platform_code=${this.platformCode}&limit=5`); - this.recentPages = response.items || response || []; - platformDetailLog.info(`Loaded ${this.recentPages.length} recent pages`); - } catch (err) { - platformDetailLog.error('Error loading recent pages:', err); - // Non-fatal - don't throw - this.recentPages = []; - } - }, - // Helper Methods getPlatformIcon(code) { const icons = { @@ -98,22 +81,6 @@ function platformDetail() { return icons[code] || 'globe-alt'; }, - getPageTypeLabel(page) { - if (page.is_platform_page) return 'Marketing'; - if (page.store_id) return 'Store Override'; - return 'Store Default'; - }, - - getPageTypeBadgeClass(page) { - if (page.is_platform_page) { - return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'; - } - if (page.store_id) { - return 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200'; - } - return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; - }, - formatDate(dateString) { if (!dateString) return '—'; const date = new Date(dateString); diff --git a/app/modules/tenancy/static/admin/js/stores.js b/app/modules/tenancy/static/admin/js/stores.js index ce6c07d1..6436d5fc 100644 --- a/app/modules/tenancy/static/admin/js/stores.js +++ b/app/modules/tenancy/static/admin/js/stores.js @@ -28,11 +28,16 @@ function adminStores() { showDeleteStoreModal: false, storeToDelete: null, + // Merchant filter (Tom Select) + selectedMerchant: null, + merchantSelectInstance: null, + // Search and filters filters: { search: '', is_active: '', - is_verified: '' + is_verified: '', + merchant_id: '' }, // Pagination state (server-side) @@ -62,9 +67,16 @@ function adminStores() { this.pagination.per_page = await window.PlatformSettings.getRowsPerPage(); } + // Initialize merchant selector (Tom Select) + this.$nextTick(() => { + this.initMerchantSelect(); + }); + storesLog.group('Loading stores data'); - await this.loadStores(); - await this.loadStats(); + await Promise.all([ + this.loadStores(), + this.loadStats(), + ]); storesLog.groupEnd(); storesLog.info('=== STORES PAGE INITIALIZATION COMPLETE ==='); @@ -163,6 +175,9 @@ function adminStores() { if (this.filters.is_verified !== '') { params.append('is_verified', this.filters.is_verified); } + if (this.filters.merchant_id !== '') { + params.append('merchant_id', this.filters.merchant_id); + } const url = `/admin/stores?${params}`; window.LogConfig.logApiCall('GET', url, null, 'request'); @@ -230,6 +245,64 @@ function adminStores() { } }, + // Initialize merchant selector (Tom Select autocomplete) + initMerchantSelect() { + if (!window.initEntitySelector) { + storesLog.warn('initEntitySelector not available yet, retrying...'); + setTimeout(() => this.initMerchantSelect(), 200); + return; + } + + this.merchantSelectInstance = initEntitySelector(this.$refs.merchantSelect, { + apiEndpoint: '/admin/merchants', + responseKey: 'merchants', + searchFields: ['name'], + codeField: null, + placeholder: 'Filter by merchant...', + noResultsText: 'No merchants found', + onSelect: (merchant) => { + storesLog.info('Merchant selected:', merchant); + this.selectedMerchant = merchant; + this.filters.merchant_id = merchant.id; + this.pagination.page = 1; + localStorage.setItem('stores_selected_merchant_id', merchant.id); + localStorage.setItem('stores_selected_merchant_data', JSON.stringify(merchant)); + this.loadStores(); + }, + onClear: () => { + this.clearMerchantFilter(); + } + }); + + // Restore from localStorage + const savedMerchantId = localStorage.getItem('stores_selected_merchant_id'); + if (savedMerchantId) { + const savedData = JSON.parse(localStorage.getItem('stores_selected_merchant_data') || 'null'); + if (savedData) { + this.selectedMerchant = savedData; + this.filters.merchant_id = parseInt(savedMerchantId); + // Wait for Tom Select to init, then set value + setTimeout(() => { + this.merchantSelectInstance?.setValue(parseInt(savedMerchantId), savedData); + }, 500); + } + } + }, + + // Clear merchant filter + clearMerchantFilter() { + storesLog.info('Clearing merchant filter'); + this.selectedMerchant = null; + this.filters.merchant_id = ''; + this.pagination.page = 1; + localStorage.removeItem('stores_selected_merchant_id'); + localStorage.removeItem('stores_selected_merchant_data'); + if (this.merchantSelectInstance) { + this.merchantSelectInstance.clear(); + } + this.loadStores(); + }, + // Pagination: Go to specific page goToPage(pageNum) { if (pageNum === '...' || pageNum < 1 || pageNum > this.totalPages) { diff --git a/app/modules/tenancy/templates/tenancy/admin/module-info.html b/app/modules/tenancy/templates/tenancy/admin/module-info.html index c05fbb33..5ac90bb5 100644 --- a/app/modules/tenancy/templates/tenancy/admin/module-info.html +++ b/app/modules/tenancy/templates/tenancy/admin/module-info.html @@ -116,7 +116,7 @@

- + Store Menu Items

@@ -171,7 +171,7 @@

- + Self-Contained Module

diff --git a/app/modules/tenancy/templates/tenancy/admin/platform-create.html b/app/modules/tenancy/templates/tenancy/admin/platform-create.html new file mode 100644 index 00000000..8138848b --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/admin/platform-create.html @@ -0,0 +1,237 @@ +{# app/templates/admin/platform-create.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/alerts.html' import loading_state %} +{% from 'shared/macros/headers.html' import edit_page_header %} + +{% block title %}Create Platform{% endblock %} + +{% block alpine_data %}platformCreate(){% endblock %} + +{% block content %} +{% call edit_page_header('Create Platform', '/admin/platforms', back_label='Back to Platforms') %} + New Platform +{% endcall %} + + +
+
+ + +
+
+ + +
+
+ +
+

+ Basic Information +

+ + + + + + + + +
+ + Description + + + +
+ +
+ + + +
+ + + + + + +
+ + +
+

+ Routing +

+ + + + + + + + +

+ Status +

+ +
+ + +
+
+
+ + +
+ + Cancel + + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/tenancy/templates/tenancy/admin/platform-detail.html b/app/modules/tenancy/templates/tenancy/admin/platform-detail.html index 83033c66..2ae98154 100644 --- a/app/modules/tenancy/templates/tenancy/admin/platform-detail.html +++ b/app/modules/tenancy/templates/tenancy/admin/platform-detail.html @@ -47,7 +47,7 @@ - +
-

Marketing Pages

-

+

Languages

+

- +
- +
-

Store Defaults

-

+

Status

+

-
- +
+
- +
@@ -170,7 +150,7 @@

- +
@@ -232,62 +212,6 @@
- -
- -
- - - - - - - - - - - - - -
TitleSlugTypeStatusUpdated
-
-
- -

No content pages yet.

- - Create your first page → - -
-
-

Created:

diff --git a/app/modules/tenancy/templates/tenancy/admin/platform-modules.html b/app/modules/tenancy/templates/tenancy/admin/platform-modules.html index fe0a66e6..f63f2766 100644 --- a/app/modules/tenancy/templates/tenancy/admin/platform-modules.html +++ b/app/modules/tenancy/templates/tenancy/admin/platform-modules.html @@ -31,7 +31,7 @@
- +

Total Modules

@@ -166,7 +166,7 @@
- +

Optional Modules

@@ -271,7 +271,7 @@
- +

No modules available.

diff --git a/app/modules/tenancy/templates/tenancy/admin/platforms.html b/app/modules/tenancy/templates/tenancy/admin/platforms.html index cfb14779..d83f29e8 100644 --- a/app/modules/tenancy/templates/tenancy/admin/platforms.html +++ b/app/modules/tenancy/templates/tenancy/admin/platforms.html @@ -10,6 +10,15 @@ {% block content %} {{ page_header('Platforms', subtitle='Manage platform configurations for OMS, Loyalty, and other business offerings') }} + + + {{ loading_state('Loading platforms...') }} {{ error_state('Error loading platforms') }} @@ -49,12 +58,12 @@

Stores

-

-

Marketing Pages

+

+

Languages

-

-

Store Defaults

+

+

Domain

@@ -80,37 +89,20 @@
- -
-

Content Page Tiers

-
-
- -
-

Platform Marketing Pages

-

Public pages like homepage, pricing, features. Not inherited by stores.

-
-
-
- -
-

Store Defaults

-

Default pages inherited by all stores (about, terms, privacy).

-
-
-
- -
-

Store Overrides

-

Custom pages created by individual stores.

-
-
-
-
{% endblock %} {% block extra_scripts %} diff --git a/app/modules/tenancy/templates/tenancy/admin/stores.html b/app/modules/tenancy/templates/tenancy/admin/stores.html index a72449ba..919e5aaa 100644 --- a/app/modules/tenancy/templates/tenancy/admin/stores.html +++ b/app/modules/tenancy/templates/tenancy/admin/stores.html @@ -5,6 +5,7 @@ {% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/tables.html' import table_wrapper, table_header %} {% from 'shared/macros/modals.html' import confirm_modal, confirm_modal_dynamic %} +{% from 'shared/macros/inputs.html' import entity_selector, entity_selected_badge %} {% block title %}Stores{% endblock %} @@ -123,6 +124,9 @@ + + {{ entity_selector(ref_name='merchantSelect', id='merchant-select', placeholder='Filter by merchant...', width='w-64') }} +
+ +{{ entity_selected_badge(entity_var='selectedMerchant', clear_fn='clearMerchantFilter()', name_field='name', color='blue') }} +
{% call table_wrapper() %} diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html index 871347fe..1e7df38e 100644 --- a/app/templates/admin/base.html +++ b/app/templates/admin/base.html @@ -141,6 +141,9 @@ + + + diff --git a/app/templates/shared/macros/inputs.html b/app/templates/shared/macros/inputs.html index ad56ff45..90470fb0 100644 --- a/app/templates/shared/macros/inputs.html +++ b/app/templates/shared/macros/inputs.html @@ -284,6 +284,102 @@ {% endmacro %} +{# + Entity Selector (Tom Select) + ============================ + A generic async searchable entity selector using Tom Select. + Works for stores, merchants, platforms, or any entity with a search API. + + Includes Tom Select dark mode CSS overrides (no need to add per page). + + Parameters: + - ref_name: Alpine.js x-ref name for the select element (default: 'entitySelect') + - id: HTML id attribute (default: 'entity-select') + - placeholder: Placeholder text (default: 'Search...') + - width: CSS width class (default: 'w-80') + - label: Accessible label (default: 'Entity selector') + + Usage: + {% from 'shared/macros/inputs.html' import entity_selector, entity_selected_badge %} + + {{ entity_selector(ref_name='merchantSelect', id='merchant-select', placeholder='Filter by merchant...') }} + {{ entity_selected_badge(entity_var='selectedMerchant', clear_fn='clearMerchantFilter()', color='blue') }} +#} +{% macro entity_selector( + ref_name='entitySelect', + id='entity-select', + placeholder='Search...', + width='w-80', + label='Entity selector' +) %} +{# Dark mode CSS is in admin/base.html — no need to duplicate here #} +
+ +
+{% endmacro %} + + +{# + Entity Selected Badge + ===================== + Displays the currently selected entity as a badge with avatar, name, optional code, and clear button. + + Parameters: + - entity_var: Alpine.js variable name holding the selected entity (e.g. 'selectedStore') + - clear_fn: Alpine.js function to call on clear (e.g. 'clearStoreFilter()') + - name_field: Field name for entity name (default: 'name') + - code_field: Field name for secondary code display (default: None, omitted if None) + - color: Color scheme - 'purple', 'blue', 'teal', 'green' (default: 'purple') + + Usage: + {{ entity_selected_badge( + entity_var='selectedStore', + clear_fn='clearStoreFilter()', + code_field='store_code', + color='purple' + ) }} +#} +{% macro entity_selected_badge( + entity_var='selectedEntity', + clear_fn='clearEntityFilter()', + name_field='name', + code_field=None, + color='purple' +) %} +{% set bg = 'bg-' ~ color ~ '-50 dark:bg-' ~ color ~ '-900/20' %} +{% set border = 'border-' ~ color ~ '-200 dark:border-' ~ color ~ '-800' %} +{% set avatar_bg = 'bg-' ~ color ~ '-100 dark:bg-' ~ color ~ '-900' %} +{% set avatar_text = 'text-' ~ color ~ '-600 dark:text-' ~ color ~ '-300' %} +{% set name_text = 'text-' ~ color ~ '-800 dark:text-' ~ color ~ '-200' %} +{% set code_text = 'text-' ~ color ~ '-600 dark:text-' ~ color ~ '-400' %} +{% set btn_text = 'text-' ~ color ~ '-600 dark:text-' ~ color ~ '-400 hover:text-' ~ color ~ '-800 dark:hover:text-' ~ color ~ '-200' %} +
+
+
+
+ +
+
+ + {% if code_field %} + + {% endif %} +
+
+ +
+
+{% endmacro %} + + {# Toggle Switch =============