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

@@ -12,6 +12,7 @@ Aggregates:
from fastapi import APIRouter
from .merchant_dashboard import merchant_dashboard_router
from .merchant_menu import merchant_menu_router
ROUTE_CONFIG = {
"prefix": "/core",
@@ -20,3 +21,4 @@ ROUTE_CONFIG = {
router = APIRouter()
router.include_router(merchant_dashboard_router, tags=["merchant-dashboard"])
router.include_router(merchant_menu_router, tags=["merchant-menu"])

View File

@@ -0,0 +1,141 @@
# app/modules/core/routes/api/merchant_menu.py
"""
Merchant menu rendering endpoint.
Provides the dynamic sidebar menu for the merchant portal:
- GET /menu/render/merchant - Get rendered merchant menu for current user
Menu sections are driven by module definitions (FrontendType.MERCHANT).
Only modules enabled on platforms the merchant is actively subscribed to
will appear in the sidebar.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header, get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
from app.utils.i18n import translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
merchant_menu_router = APIRouter(prefix="/menu")
# =============================================================================
# Schemas
# =============================================================================
class MenuSectionResponse(BaseModel):
"""Menu section for rendering."""
id: str
label: str | None = None
items: list[dict[str, Any]]
class RenderedMenuResponse(BaseModel):
"""Rendered menu for frontend."""
frontend_type: str
sections: list[MenuSectionResponse]
# =============================================================================
# Helpers
# =============================================================================
def _translate_label(label_key: str | None, language: str) -> str | None:
"""Translate a label key, falling back to a readable version of the key."""
if not label_key:
return None
translated = translate(label_key, language=language)
# If translation returned the key itself, create a readable fallback
if translated == label_key:
parts = label_key.split(".")
last_part = parts[-1]
return last_part.replace("_", " ").title()
return translated
# =============================================================================
# Endpoint
# =============================================================================
@merchant_menu_router.get("/render/merchant", response_model=RenderedMenuResponse)
async def get_rendered_merchant_menu(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
):
"""
Get the rendered merchant menu for the current user.
Returns the filtered menu structure based on modules enabled
on platforms the merchant is subscribed to.
Used by the merchant frontend to render the sidebar dynamically.
"""
# Resolve the merchant for this user (via service layer)
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
if not merchant:
# No merchant found — return empty menu
return RenderedMenuResponse(
frontend_type=FrontendType.MERCHANT.value,
sections=[],
)
# Get union of enabled module codes across all subscribed platforms
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
# Get filtered menu using enabled_module_codes override
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled_codes,
)
# Resolve language
language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
# Translate section and item labels
sections = []
for section in menu:
translated_items = []
for item in section.items:
translated_items.append(
{
"id": item.id,
"label": _translate_label(item.label_key, language),
"icon": item.icon,
"url": item.route,
}
)
sections.append(
MenuSectionResponse(
id=section.id,
label=_translate_label(section.label_key, language),
items=translated_items,
)
)
return RenderedMenuResponse(
frontend_type=FrontendType.MERCHANT.value,
sections=sections,
)