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') %}
Stores List
@@ -53,7 +53,7 @@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 @@
Marketing Pages
- +Languages
+Store Defaults
- +Status
+| Title | -Slug | -Type | -Status | -Updated | -
|---|---|---|---|---|
| - - | -
-
- |
- - - | -- Published - Draft - | -- |
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 @@No modules available.
Stores
Marketing Pages
+ +Languages
Store Defaults
+ +Domain
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.
-