feat: dynamic merchant sidebar with module-driven menus

Replace the hardcoded merchant sidebar with a dynamic menu system driven
by module definitions, matching the existing admin frontend pattern.
Modules declare FrontendType.MERCHANT menus in their definition.py, and
a new API endpoint unions enabled modules across all platforms the
merchant is subscribed to — so loyalty only appears when enabled.

- Add MERCHANT menu definitions to core, billing, tenancy, loyalty modules
- Extend MenuDiscoveryService with enabled_module_codes parameter
- Create GET /merchants/core/menu/render/merchant endpoint
- Update merchant Alpine.js with loadMenuConfig() and dynamic section state
- Replace hardcoded sidebar.html with x-for rendering + loading skeleton + fallback
- Add 36 unit and integration tests for menu discovery, service, and endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 00:24:11 +01:00
parent 716a4e3d15
commit be248222bc
13 changed files with 1241 additions and 82 deletions

View File

@@ -21,11 +21,8 @@ function getMerchantSidebarSectionsFromStorage() {
} catch (e) {
console.warn('[MERCHANT INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
billing: true,
account: true
};
// Default: empty — dynamic menu will initialize section state
return {};
}
function saveMerchantSidebarSectionsToStorage(sections) {
@@ -36,6 +33,19 @@ function saveMerchantSidebarSectionsToStorage(sections) {
}
}
// Helper to find section ID for a page from menu data
function findSectionForPage(menuData, pageId) {
if (!menuData?.sections) return null;
for (const section of menuData.sections) {
for (const item of (section.items || [])) {
if (item.id === pageId) {
return section.id;
}
}
}
return null;
}
function data() {
console.log('[MERCHANT INIT-ALPINE] data() function called');
return {
@@ -48,6 +58,10 @@ function data() {
// Sidebar collapsible sections state
openSections: getMerchantSidebarSectionsFromStorage(),
// Dynamic menu (loaded from API)
menuData: null,
menuLoading: false,
init() {
// Set current page from URL
const path = window.location.pathname;
@@ -87,6 +101,9 @@ function data() {
// Ignore storage errors
}
}
// Load dynamic menu
this.loadMenuConfig();
},
toggleSideMenu() {
@@ -116,6 +133,54 @@ function data() {
saveMerchantSidebarSectionsToStorage(this.openSections);
},
// Auto-expand section containing current page
expandSectionForCurrentPage() {
if (!this.menuData) return;
const section = findSectionForPage(this.menuData, this.currentPage);
if (section && !this.openSections[section]) {
this.openSections[section] = true;
saveMerchantSidebarSectionsToStorage(this.openSections);
}
},
// Dynamic menu loading from API
async loadMenuConfig() {
if (this.menuData || this.menuLoading) return;
// Skip if apiClient is not available (e.g., on login page)
if (typeof apiClient === 'undefined') {
console.debug('Menu config: apiClient not available');
return;
}
// Skip if not authenticated
if (!localStorage.getItem('merchant_token')) {
console.debug('Menu config: no merchant_token, skipping');
return;
}
this.menuLoading = true;
try {
this.menuData = await apiClient.get('/merchants/core/menu/render/merchant');
const sections = this.menuData?.sections || [];
for (const section of sections) {
// Initialize openSections for new sections (default: open)
if (this.openSections[section.id] === undefined) {
this.openSections[section.id] = true;
}
}
saveMerchantSidebarSectionsToStorage(this.openSections);
console.debug('Menu config loaded:', sections.length, 'sections');
// Auto-expand section containing current page
this.expandSectionForCurrentPage();
} catch (e) {
console.debug('Menu config not loaded, using fallback:', e?.message || e);
} finally {
this.menuLoading = false;
}
},
async handleLogout() {
console.log('Logging out merchant user...');