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

@@ -123,6 +123,7 @@ class MenuDiscoveryService:
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
enabled_module_codes: set[str] | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get aggregated menu sections for a frontend type.
@@ -134,6 +135,9 @@ class MenuDiscoveryService:
db: Database session
frontend_type: Frontend type to get menus for
platform_id: Platform ID for module enablement filtering
enabled_module_codes: If provided, overrides single-platform lookup.
A module is considered enabled if its code is in this set.
Useful for merchant portal where a merchant may span multiple platforms.
Returns:
List of DiscoveredMenuSection sorted by order
@@ -144,12 +148,15 @@ class MenuDiscoveryService:
sections_map: dict[str, DiscoveredMenuSection] = {}
for module_code, module_def in MODULES.items():
# Check if module is enabled for this platform
is_module_enabled = True
if platform_id:
# Check if module is enabled
if enabled_module_codes is not None:
is_module_enabled = module_code in enabled_module_codes
elif platform_id:
is_module_enabled = module_service.is_module_enabled(
db, platform_id, module_code
)
else:
is_module_enabled = True
# Get menu sections for this frontend type
module_sections = module_def.menus.get(frontend_type, [])
@@ -204,6 +211,7 @@ class MenuDiscoveryService:
user_id: int | None = None,
is_super_admin: bool = False,
store_code: str | None = None,
enabled_module_codes: set[str] | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get filtered menu structure for frontend rendering.
@@ -221,12 +229,16 @@ class MenuDiscoveryService:
user_id: User ID for user-specific visibility (super admins only)
is_super_admin: Whether the user is a super admin
store_code: Store code for route placeholder replacement
enabled_module_codes: If provided, overrides single-platform lookup
for module enablement. Passed through to get_menu_sections_for_frontend.
Returns:
List of DiscoveredMenuSection with filtered and sorted items
"""
# Get all sections with module enablement filtering
sections = self.get_menu_sections_for_frontend(db, frontend_type, platform_id)
sections = self.get_menu_sections_for_frontend(
db, frontend_type, platform_id, enabled_module_codes=enabled_module_codes
)
# Get visibility configuration
visible_item_ids = self._get_visible_item_ids(

View File

@@ -228,6 +228,7 @@ class MenuService:
user_id: int | None = None,
is_super_admin: bool = False,
store_code: str | None = None,
enabled_module_codes: set[str] | None = None,
) -> list:
"""
Get filtered menu structure for frontend rendering.
@@ -241,11 +242,14 @@ class MenuService:
Args:
db: Database session
frontend_type: Which frontend (admin or store)
frontend_type: Which frontend (admin, store, or merchant)
platform_id: Platform ID (for platform admins and stores)
user_id: User ID (for super admins only)
is_super_admin: Whether user is super admin (affects admin-only sections)
store_code: Store code for URL placeholder replacement (store frontend)
enabled_module_codes: If provided, overrides single-platform lookup
for module enablement. Used by merchant portal where a merchant
may have subscriptions across multiple platforms.
Returns:
List of DiscoveredMenuSection ready for rendering
@@ -257,6 +261,95 @@ class MenuService:
user_id=user_id,
is_super_admin=is_super_admin,
store_code=store_code,
enabled_module_codes=enabled_module_codes,
)
# =========================================================================
# Merchant Menu
# =========================================================================
def get_merchant_enabled_module_codes(
self,
db: Session,
merchant_id: int,
) -> set[str]:
"""
Get the union of enabled module codes across all platforms the merchant
has an active subscription on.
Core modules (those with is_core=True) are always included.
Args:
db: Database session
merchant_id: Merchant ID
Returns:
Set of enabled module codes
"""
from app.modules.billing.models.merchant_subscription import (
MerchantSubscription,
)
from app.modules.billing.models.subscription import SubscriptionStatus
from app.modules.registry import MODULES
# Always include core modules
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
# Find all platform IDs where merchant has active/trial subscriptions
active_statuses = [
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.PAST_DUE.value,
SubscriptionStatus.CANCELLED.value,
]
subscriptions = (
db.query(MerchantSubscription.platform_id)
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.status.in_(active_statuses),
)
.all()
)
platform_ids = {sub.platform_id for sub in subscriptions}
if not platform_ids:
return core_codes
# Union enabled module codes across all subscribed platforms
all_enabled = set(core_codes)
for platform_id in platform_ids:
platform_codes = module_service.get_enabled_module_codes(db, platform_id)
all_enabled |= platform_codes
return all_enabled
def get_merchant_for_menu(
self,
db: Session,
user_id: int,
):
"""
Get the active merchant owned by a user, for menu rendering.
Args:
db: Database session
user_id: Owner user ID
Returns:
Merchant ORM object or None
"""
from app.modules.tenancy.models import Merchant
return (
db.query(Merchant)
.filter(
Merchant.owner_user_id == user_id,
Merchant.is_active == True, # noqa: E712
)
.order_by(Merchant.id)
.first()
)
# =========================================================================