feat: complete dynamic menu system across all frontends
All checks were successful
CI / ruff (push) Successful in 11s
CI / pytest (push) Successful in 44m40s
CI / validate (push) Successful in 22s
CI / dependency-scanning (push) Successful in 28s
CI / docs (push) Successful in 39s
CI / deploy (push) Successful in 49s

- Add "Merchant Frontend" tab to admin menu-config page
- Merchant render endpoint now respects AdminMenuConfig visibility
  via get_merchant_primary_platform_id() platform resolution
- New store menu render endpoint (GET /store/core/menu/render/store)
  with platform-scoped visibility and store_code interpolation
- Store sidebar migrated from hardcoded Jinja2 macros to dynamic
  Alpine.js x-for rendering with loading skeleton and fallback
- Store init-alpine.js: add loadMenuConfig(), expandSectionForCurrentPage()
- Include store page route fixes, login template updates, and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 02:14:42 +01:00
parent be248222bc
commit 506171503d
14 changed files with 1364 additions and 158 deletions

View File

@@ -101,10 +101,16 @@ async def get_rendered_merchant_menu(
# 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
# Resolve primary platform for AdminMenuConfig visibility lookup
primary_platform_id = menu_service.get_merchant_primary_platform_id(
db, merchant.id
)
# Get filtered menu using enabled_module_codes override + platform visibility
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
platform_id=primary_platform_id,
enabled_module_codes=enabled_codes,
)

View File

@@ -5,15 +5,18 @@ Core module store API routes.
Aggregates:
- /dashboard/* - Dashboard statistics
- /settings/* - Store settings management
- /menu/* - Store menu rendering
"""
from fastapi import APIRouter
from .store_dashboard import store_dashboard_router
from .store_menu import store_menu_router
from .store_settings import store_settings_router
store_router = APIRouter()
# Aggregate sub-routers
store_router.include_router(store_dashboard_router, tags=["store-dashboard"])
store_router.include_router(store_menu_router, tags=["store-menu"])
store_router.include_router(store_settings_router, tags=["store-settings"])

View File

@@ -0,0 +1,139 @@
# app/modules/core/routes/api/store_menu.py
"""
Store menu rendering endpoint.
Provides the dynamic sidebar menu for the store portal:
- GET /menu/render/store - Get rendered store menu for current user
Menu sections are driven by module definitions (FrontendType.STORE).
Only modules enabled on the store's platform will appear in the sidebar.
Visibility is controlled by AdminMenuConfig records for the platform.
"""
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_store_api
from app.core.database import get_db
from app.modules.core.services.menu_service import menu_service
from app.modules.enums import FrontendType
from app.modules.tenancy.services.store_service import store_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
logger = logging.getLogger(__name__)
store_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
# =============================================================================
@store_menu_router.get("/render/store", response_model=RenderedMenuResponse)
async def get_rendered_store_menu(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_store_api),
):
"""
Get the rendered store menu for the current user.
Returns the filtered menu structure based on:
- Modules enabled on the store's platform
- AdminMenuConfig visibility for the platform
- Store code for URL placeholder replacement
Used by the store frontend to render the sidebar dynamically.
"""
# Get the store from the JWT token's store context
store = store_service.get_store_by_id(db, current_user.token_store_id)
# Resolve the store's platform via service layer
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
# Get filtered menu with platform visibility and store_code interpolation
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.STORE,
platform_id=platform_id,
store_code=store.subdomain,
)
# 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.STORE.value,
sections=sections,
)

View File

@@ -3,6 +3,7 @@
Core Store Page Routes (HTML rendering).
Store pages for core functionality:
- Dashboard
- Media library
- Notifications
"""
@@ -19,6 +20,35 @@ from app.templates_config import templates
router = APIRouter()
# ============================================================================
# STORE DASHBOARD
# ============================================================================
@router.get(
"/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
)
async def store_dashboard_page(
request: Request,
store_code: str = Path(..., description="Store code"),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render store dashboard.
JavaScript will:
- Load store info via API
- Load dashboard stats via API
- Load recent orders via API
- Handle all interactivity
"""
return templates.TemplateResponse(
"core/store/dashboard.html",
get_store_context(request, db, current_user, store_code),
)
# ============================================================================
# MEDIA LIBRARY
# ============================================================================

View File

@@ -325,6 +325,106 @@ class MenuService:
return all_enabled
def get_merchant_primary_platform_id(
self,
db: Session,
merchant_id: int,
) -> int | None:
"""
Get the primary platform ID for a merchant's visibility config.
Resolution order:
1. Platform from the store marked is_primary in StorePlatform
2. First active subscription's platform (fallback)
Args:
db: Database session
merchant_id: Merchant ID
Returns:
Platform ID or None if no active subscriptions
"""
from app.modules.billing.models.merchant_subscription import (
MerchantSubscription,
)
from app.modules.billing.models.subscription import SubscriptionStatus
from app.modules.tenancy.models import Store
from app.modules.tenancy.models.store_platform import StorePlatform
active_statuses = [
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.PAST_DUE.value,
SubscriptionStatus.CANCELLED.value,
]
# Try to find the primary store's platform
primary_platform_id = (
db.query(StorePlatform.platform_id)
.join(Store, Store.id == StorePlatform.store_id)
.join(
MerchantSubscription,
(MerchantSubscription.platform_id == StorePlatform.platform_id)
& (MerchantSubscription.merchant_id == merchant_id),
)
.filter(
Store.merchant_id == merchant_id,
Store.is_active == True, # noqa: E712
StorePlatform.is_primary == True, # noqa: E712
StorePlatform.is_active == True, # noqa: E712
MerchantSubscription.status.in_(active_statuses),
)
.first()
)
if primary_platform_id:
return primary_platform_id[0]
# Fallback: first active subscription's platform
first_sub = (
db.query(MerchantSubscription.platform_id)
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.status.in_(active_statuses),
)
.order_by(MerchantSubscription.id)
.first()
)
return first_sub[0] if first_sub else None
def get_store_primary_platform_id(
self,
db: Session,
store_id: int,
) -> int | None:
"""
Get the primary platform ID for a store's menu visibility config.
Prefers the active StorePlatform marked is_primary, falls back to
the first active StorePlatform by ID.
Args:
db: Database session
store_id: Store ID
Returns:
Platform ID or None if no active store-platform link
"""
from app.modules.tenancy.models.store_platform import StorePlatform
sp = (
db.query(StorePlatform.platform_id)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.is_active == True, # noqa: E712
)
.order_by(StorePlatform.is_primary.desc(), StorePlatform.id)
.first()
)
return sp[0] if sp else None
def get_merchant_for_menu(
self,
db: Session,

View File

@@ -21,14 +21,20 @@ function getStoreSidebarSectionsFromStorage() {
} catch (e) {
console.warn('[STORE INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
products: true,
sales: true,
customers: true,
shop: true,
account: true
};
// Default: empty (populated dynamically from API response)
return {};
}
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 saveStoreSidebarSectionsToStorage(sections) {
@@ -51,6 +57,10 @@ function data() {
store: null,
storeCode: null,
// Dynamic menu state
menuData: null,
menuLoading: false,
// Sidebar collapsible sections state
openSections: getStoreSidebarSectionsFromStorage(),
@@ -81,8 +91,9 @@ function data() {
this.dark = true;
}
// Load store info
// Load store info and dynamic menu
this.loadStoreInfo();
this.loadMenuConfig();
// Save last visited page (for redirect after login)
// Exclude login, logout, onboarding, error pages
@@ -111,6 +122,38 @@ function data() {
}
},
async loadMenuConfig() {
if (this.menuData || this.menuLoading) return;
if (typeof apiClient === 'undefined') return;
if (!localStorage.getItem('store_token')) return;
this.menuLoading = true;
try {
this.menuData = await apiClient.get('/store/core/menu/render/store');
// Initialize section open state from response
for (const section of (this.menuData?.sections || [])) {
if (this.openSections[section.id] === undefined) {
this.openSections[section.id] = true;
}
}
saveStoreSidebarSectionsToStorage(this.openSections);
this.expandSectionForCurrentPage();
} catch (e) {
console.debug('Menu config not loaded, using fallback:', e?.message || e);
} finally {
this.menuLoading = false;
}
},
expandSectionForCurrentPage() {
if (!this.menuData) return;
const sectionId = findSectionForPage(this.menuData, this.currentPage);
if (sectionId && !this.openSections[sectionId]) {
this.openSections[sectionId] = true;
saveStoreSidebarSectionsToStorage(this.openSections);
}
},
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen;
},

View File

@@ -0,0 +1,200 @@
# app/modules/core/tests/integration/test_store_dashboard_routes.py
"""
Integration tests for store dashboard page route.
Tests the store dashboard page at:
GET /store/{store_code}/dashboard
Verifies:
- Dashboard renders without any onboarding check
- Dashboard requires authentication
- Dashboard is served from the core module (no marketplace dependency)
"""
import uuid
from datetime import UTC, datetime
import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store_platform import StorePlatform
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sd_owner(db):
"""Create a store owner user."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"sdowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"sdowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def sd_platform(db):
"""Create a platform without marketplace enabled."""
platform = Platform(
code=f"sdp_{uuid.uuid4().hex[:8]}",
name="Loyalty Only Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def sd_merchant(db, sd_owner):
"""Create a merchant."""
merchant = Merchant(
name="Dashboard Test Merchant",
owner_user_id=sd_owner.id,
contact_email=sd_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def sd_store(db, sd_merchant):
"""Create a store."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=sd_merchant.id,
store_code=f"SDSTORE_{uid.upper()}",
subdomain=f"sdstore{uid.lower()}",
name="Dashboard Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def sd_store_platform(db, sd_store, sd_platform):
"""Link store to a loyalty-only platform (no marketplace)."""
sp = StorePlatform(
store_id=sd_store.id,
platform_id=sd_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def sd_auth(sd_owner, sd_store):
"""Override auth dependency for store cookie/header auth."""
user_context = UserContext(
id=sd_owner.id,
email=sd_owner.email,
username=sd_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=sd_store.id,
token_store_code=sd_store.store_code,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestStoreDashboardPage:
"""Tests for GET /store/{store_code}/dashboard."""
def test_dashboard_renders_for_authenticated_store_user(
self, client, db, sd_auth, sd_store, sd_store_platform
):
"""Dashboard page returns 200 for authenticated store user."""
response = client.get(
f"/store/{sd_store.subdomain}/dashboard",
headers=sd_auth,
follow_redirects=False,
)
assert response.status_code == 200
def test_dashboard_renders_without_onboarding_check(
self, client, db, sd_auth, sd_store, sd_store_platform
):
"""Dashboard loads without any onboarding redirect — no marketplace dependency.
On a loyalty-only platform, there's no StoreOnboarding record,
yet the dashboard should still render successfully.
"""
response = client.get(
f"/store/{sd_store.subdomain}/dashboard",
headers=sd_auth,
follow_redirects=False,
)
# Should NOT redirect to onboarding
assert response.status_code == 200
assert "location" not in response.headers
def test_dashboard_requires_auth(self, client):
"""Returns 401 without auth."""
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
response = client.get(
"/store/anystore/dashboard",
follow_redirects=False,
)
assert response.status_code == 401
@pytest.mark.integration
@pytest.mark.core
class TestStoreDashboardNoMarketplace:
"""Tests verifying dashboard has no marketplace module dependency."""
def test_dashboard_has_no_onboarding_import(self):
"""Core store routes module does not import OnboardingService."""
import app.modules.core.routes.pages.store as core_store_module
with open(core_store_module.__file__) as f:
source = f.read()
assert "OnboardingService" not in source
assert "onboarding" not in source.lower()
def test_dashboard_has_no_marketplace_import(self):
"""Core store routes module does not import from marketplace."""
import app.modules.core.routes.pages.store as core_store_module
with open(core_store_module.__file__) as f:
source = f.read()
assert "app.modules.marketplace" not in source

View File

@@ -4,7 +4,6 @@ Marketplace Store Page Routes (HTML rendering).
Store pages for marketplace management:
- Onboarding wizard
- Dashboard
- Marketplace imports
- Letzshop integration
"""
@@ -16,7 +15,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_store_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_store_context
from app.modules.marketplace.services.onboarding_service import OnboardingService
from app.modules.service import module_service
from app.modules.tenancy.models import User
from app.modules.tenancy.models.store_platform import StorePlatform
from app.templates_config import templates
router = APIRouter()
@@ -39,14 +40,27 @@ async def store_onboarding_page(
"""
Render store onboarding wizard.
Mandatory 4-step wizard that must be completed before accessing dashboard:
4-step wizard for marketplace setup:
1. Merchant Profile Setup
2. Letzshop API Configuration
3. Product & Order Import Configuration
4. Order Sync (historical import)
If onboarding is already completed, redirects to dashboard.
If marketplace module is not enabled for this store's platform,
redirects to dashboard. If onboarding is already completed,
redirects to dashboard.
"""
sp = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == current_user.token_store_id)
.first()
)
if not sp or not module_service.is_module_enabled(db, sp.platform_id, "marketplace"):
return RedirectResponse(
url=f"/store/{store_code}/dashboard",
status_code=302,
)
onboarding_service = OnboardingService(db)
if onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
@@ -60,44 +74,6 @@ async def store_onboarding_page(
)
# ============================================================================
# STORE DASHBOARD
# ============================================================================
@router.get(
"/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
)
async def store_dashboard_page(
request: Request,
store_code: str = Path(..., description="Store code"),
current_user: User = Depends(get_current_store_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render store dashboard.
Redirects to onboarding if not completed.
JavaScript will:
- Load store info via API
- Load dashboard stats via API
- Load recent orders via API
- Handle all interactivity
"""
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
url=f"/store/{store_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse(
"core/store/dashboard.html",
get_store_context(request, db, current_user, store_code),
)
# ============================================================================
# MARKETPLACE IMPORTS
# ============================================================================
@@ -115,7 +91,15 @@ async def store_marketplace_page(
"""
Render marketplace import page.
JavaScript loads import jobs and products via API.
Redirects to onboarding if not completed.
"""
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
url=f"/store/{store_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse(
"marketplace/store/marketplace.html",
get_store_context(request, db, current_user, store_code),
@@ -139,7 +123,15 @@ async def store_letzshop_page(
"""
Render Letzshop integration page.
JavaScript loads orders, credentials status, and handles fulfillment operations.
Redirects to onboarding if not completed.
"""
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
url=f"/store/{store_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse(
"marketplace/store/letzshop.html",
get_store_context(request, db, current_user, store_code),

View File

@@ -0,0 +1,388 @@
# app/modules/marketplace/tests/integration/test_store_page_routes.py
"""
Integration tests for marketplace store page routes.
Tests the onboarding, marketplace, and letzshop page routes at:
GET /store/{store_code}/onboarding
GET /store/{store_code}/marketplace
GET /store/{store_code}/letzshop
Verifies:
- Onboarding page redirects to dashboard on non-marketplace platforms
- Onboarding page redirects to dashboard when already completed
- Onboarding page renders when marketplace enabled and not completed
- Marketplace/Letzshop pages redirect to onboarding when not completed
- Marketplace/Letzshop pages render when onboarding is completed
"""
import uuid
from datetime import UTC, datetime
import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.marketplace.models import OnboardingStatus, StoreOnboarding
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.platform_module import PlatformModule
from app.modules.tenancy.models.store_platform import StorePlatform
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def mp_admin(db):
"""Create an admin user for enabling modules."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"mpadmin_{uuid.uuid4().hex[:8]}@test.com",
username=f"mpadmin_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="super_admin",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def mp_owner(db):
"""Create a store owner user."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"mpowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"mpowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def mp_platform_no_marketplace(db):
"""Create a platform without marketplace module enabled."""
platform = Platform(
code=f"mpnm_{uuid.uuid4().hex[:8]}",
name="No Marketplace Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def mp_platform_with_marketplace(db, mp_admin):
"""Create a platform with marketplace module enabled."""
platform = Platform(
code=f"mpmk_{uuid.uuid4().hex[:8]}",
name="Marketplace Platform",
is_active=True,
)
db.add(platform)
db.flush()
pm = PlatformModule(
platform_id=platform.id,
module_code="marketplace",
is_enabled=True,
enabled_at=datetime.now(UTC),
enabled_by_user_id=mp_admin.id,
config={},
)
db.add(pm)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def mp_merchant(db, mp_owner):
"""Create a merchant."""
merchant = Merchant(
name="MP Test Merchant",
owner_user_id=mp_owner.id,
contact_email=mp_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def mp_store(db, mp_merchant):
"""Create a store."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=mp_merchant.id,
store_code=f"MPSTORE_{uid.upper()}",
subdomain=f"mpstore{uid.lower()}",
name="MP Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def mp_store_no_marketplace(db, mp_store, mp_platform_no_marketplace):
"""Link store to a platform without marketplace."""
sp = StorePlatform(
store_id=mp_store.id,
platform_id=mp_platform_no_marketplace.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def mp_store_with_marketplace(db, mp_store, mp_platform_with_marketplace):
"""Link store to a platform with marketplace enabled."""
sp = StorePlatform(
store_id=mp_store.id,
platform_id=mp_platform_with_marketplace.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def mp_onboarding_not_completed(db, mp_store):
"""Create an incomplete onboarding record for the store."""
onboarding = StoreOnboarding(
store_id=mp_store.id,
status=OnboardingStatus.IN_PROGRESS.value,
)
db.add(onboarding)
db.commit()
db.refresh(onboarding)
return onboarding
@pytest.fixture
def mp_onboarding_completed(db, mp_store):
"""Create a completed onboarding record for the store."""
onboarding = StoreOnboarding(
store_id=mp_store.id,
status=OnboardingStatus.COMPLETED.value,
step_merchant_profile_completed=True,
step_letzshop_api_completed=True,
step_product_import_completed=True,
step_order_sync_completed=True,
)
db.add(onboarding)
db.commit()
db.refresh(onboarding)
return onboarding
@pytest.fixture
def mp_auth(mp_owner, mp_store):
"""Override auth dependency for store cookie/header auth."""
user_context = UserContext(
id=mp_owner.id,
email=mp_owner.email,
username=mp_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=mp_store.id,
token_store_code=mp_store.store_code,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
# ============================================================================
# Onboarding page tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.marketplace
class TestOnboardingPageRoutes:
"""Tests for GET /store/{store_code}/onboarding."""
def test_redirects_to_dashboard_on_non_marketplace_platform(
self, client, db, mp_auth, mp_store, mp_store_no_marketplace
):
"""Onboarding page redirects to dashboard on platform without marketplace."""
response = client.get(
f"/store/{mp_store.subdomain}/onboarding",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 302
assert f"/store/{mp_store.subdomain}/dashboard" in response.headers["location"]
def test_redirects_to_dashboard_when_onboarding_completed(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
mp_onboarding_completed,
):
"""Onboarding page redirects to dashboard when already completed."""
response = client.get(
f"/store/{mp_store.subdomain}/onboarding",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 302
assert f"/store/{mp_store.subdomain}/dashboard" in response.headers["location"]
def test_renders_onboarding_when_not_completed(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
mp_onboarding_not_completed,
):
"""Onboarding page renders wizard when marketplace enabled and not completed."""
response = client.get(
f"/store/{mp_store.subdomain}/onboarding",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 200
def test_renders_onboarding_when_no_onboarding_record(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
):
"""Onboarding page renders wizard when marketplace enabled and no record exists."""
# No mp_onboarding_* fixture — is_completed() returns False for missing record
response = client.get(
f"/store/{mp_store.subdomain}/onboarding",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 200
def test_requires_auth(self, client):
"""Returns 401 without auth."""
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
response = client.get(
"/store/anystore/onboarding",
follow_redirects=False,
)
assert response.status_code == 401
# ============================================================================
# Marketplace page tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.marketplace
class TestMarketplacePageRoutes:
"""Tests for GET /store/{store_code}/marketplace."""
def test_redirects_to_onboarding_when_not_completed(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
mp_onboarding_not_completed,
):
"""Marketplace page redirects to onboarding when not completed."""
response = client.get(
f"/store/{mp_store.subdomain}/marketplace",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 302
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]
def test_renders_when_onboarding_completed(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
mp_onboarding_completed,
):
"""Marketplace page renders when onboarding is completed."""
response = client.get(
f"/store/{mp_store.subdomain}/marketplace",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 200
def test_redirects_when_no_onboarding_record(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
):
"""Marketplace page redirects when no onboarding record (not completed)."""
response = client.get(
f"/store/{mp_store.subdomain}/marketplace",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 302
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]
# ============================================================================
# Letzshop page tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.marketplace
class TestLetzshopPageRoutes:
"""Tests for GET /store/{store_code}/letzshop."""
def test_redirects_to_onboarding_when_not_completed(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
mp_onboarding_not_completed,
):
"""Letzshop page redirects to onboarding when not completed."""
response = client.get(
f"/store/{mp_store.subdomain}/letzshop",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 302
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]
def test_renders_when_onboarding_completed(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
mp_onboarding_completed,
):
"""Letzshop page renders when onboarding is completed."""
response = client.get(
f"/store/{mp_store.subdomain}/letzshop",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 200
def test_redirects_when_no_onboarding_record(
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
):
"""Letzshop page redirects when no onboarding record (not completed)."""
response = client.get(
f"/store/{mp_store.subdomain}/letzshop",
headers=mp_auth,
follow_redirects=False,
)
assert response.status_code == 302
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]

View File

@@ -0,0 +1,298 @@
# app/modules/marketplace/tests/unit/test_store_page_routes.py
"""
Unit tests for marketplace store page routes.
Tests the onboarding, marketplace, and letzshop page route logic:
- Onboarding page guards (marketplace module check, completion redirect)
- Marketplace page onboarding gate
- Letzshop page onboarding gate
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi import Request
from fastapi.responses import RedirectResponse
from app.modules.marketplace.routes.pages.store import (
store_letzshop_page,
store_marketplace_page,
store_onboarding_page,
)
from models.schema.auth import UserContext
def _make_user_context(store_id: int = 1, store_code: str = "teststore") -> UserContext:
"""Create a UserContext for testing."""
return UserContext(
id=1,
email="test@test.com",
username="testuser",
role="merchant_owner",
is_active=True,
token_store_id=store_id,
token_store_code=store_code,
)
def _make_request() -> MagicMock:
"""Create a mock Request."""
return MagicMock(spec=Request)
# ============================================================================
# ONBOARDING PAGE
# ============================================================================
@pytest.mark.unit
@pytest.mark.marketplace
@pytest.mark.asyncio
class TestOnboardingPageGuard:
"""Test that onboarding page redirects to dashboard when marketplace is not enabled."""
@patch("app.modules.marketplace.routes.pages.store.module_service")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_redirects_to_dashboard_when_no_store_platform(
self, mock_templates, mock_ctx, mock_module_service, db
):
"""Redirect to dashboard if store has no StorePlatform record."""
user = _make_user_context(store_id=99999)
response = await store_onboarding_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert isinstance(response, RedirectResponse)
assert response.status_code == 302
assert "/store/teststore/dashboard" in response.headers["location"]
@patch("app.modules.marketplace.routes.pages.store.module_service")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_redirects_to_dashboard_when_marketplace_not_enabled(
self, mock_templates, mock_ctx, mock_module_service, db, test_store
):
"""Redirect to dashboard if marketplace module is not enabled on platform."""
from app.modules.tenancy.models import Platform
from app.modules.tenancy.models.store_platform import StorePlatform
platform = Platform(code="nomarket", name="No Marketplace", is_active=True)
db.add(platform)
db.flush()
sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True)
db.add(sp)
db.commit()
mock_module_service.is_module_enabled.return_value = False
user = _make_user_context(store_id=test_store.id)
response = await store_onboarding_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert isinstance(response, RedirectResponse)
assert response.status_code == 302
assert "/store/teststore/dashboard" in response.headers["location"]
mock_module_service.is_module_enabled.assert_called_once_with(
db, platform.id, "marketplace"
)
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
@patch("app.modules.marketplace.routes.pages.store.module_service")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_redirects_to_dashboard_when_onboarding_completed(
self, mock_templates, mock_ctx, mock_module_service, mock_onboarding_cls, db, test_store
):
"""Redirect to dashboard if onboarding is already completed."""
from app.modules.tenancy.models import Platform
from app.modules.tenancy.models.store_platform import StorePlatform
platform = Platform(code="mktplace", name="Marketplace", is_active=True)
db.add(platform)
db.flush()
sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True)
db.add(sp)
db.commit()
mock_module_service.is_module_enabled.return_value = True
mock_svc = MagicMock()
mock_svc.is_completed.return_value = True
mock_onboarding_cls.return_value = mock_svc
user = _make_user_context(store_id=test_store.id)
response = await store_onboarding_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert isinstance(response, RedirectResponse)
assert response.status_code == 302
assert "/store/teststore/dashboard" in response.headers["location"]
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
@patch("app.modules.marketplace.routes.pages.store.module_service")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_renders_onboarding_when_marketplace_enabled_and_not_completed(
self, mock_templates, mock_ctx, mock_module_service, mock_onboarding_cls, db, test_store
):
"""Render onboarding wizard when marketplace enabled and onboarding not completed."""
from app.modules.tenancy.models import Platform
from app.modules.tenancy.models.store_platform import StorePlatform
platform = Platform(code="mktplace2", name="Marketplace 2", is_active=True)
db.add(platform)
db.flush()
sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True)
db.add(sp)
db.commit()
mock_module_service.is_module_enabled.return_value = True
mock_svc = MagicMock()
mock_svc.is_completed.return_value = False
mock_onboarding_cls.return_value = mock_svc
mock_ctx.return_value = {"request": _make_request()}
mock_templates.TemplateResponse.return_value = "rendered"
user = _make_user_context(store_id=test_store.id)
response = await store_onboarding_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert response == "rendered"
mock_templates.TemplateResponse.assert_called_once()
template_name = mock_templates.TemplateResponse.call_args[0][0]
assert template_name == "marketplace/store/onboarding.html"
# ============================================================================
# MARKETPLACE PAGE
# ============================================================================
@pytest.mark.unit
@pytest.mark.marketplace
@pytest.mark.asyncio
class TestMarketplacePageOnboardingGate:
"""Test that marketplace page redirects to onboarding when not completed."""
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_redirects_to_onboarding_when_not_completed(
self, mock_templates, mock_ctx, mock_onboarding_cls, db
):
"""Redirect to onboarding if onboarding not completed."""
mock_svc = MagicMock()
mock_svc.is_completed.return_value = False
mock_onboarding_cls.return_value = mock_svc
user = _make_user_context()
response = await store_marketplace_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert isinstance(response, RedirectResponse)
assert response.status_code == 302
assert "/store/teststore/onboarding" in response.headers["location"]
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_renders_marketplace_when_onboarding_completed(
self, mock_templates, mock_ctx, mock_onboarding_cls, db
):
"""Render marketplace page if onboarding is completed."""
mock_svc = MagicMock()
mock_svc.is_completed.return_value = True
mock_onboarding_cls.return_value = mock_svc
mock_ctx.return_value = {"request": _make_request()}
mock_templates.TemplateResponse.return_value = "rendered"
user = _make_user_context()
response = await store_marketplace_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert response == "rendered"
mock_templates.TemplateResponse.assert_called_once()
template_name = mock_templates.TemplateResponse.call_args[0][0]
assert template_name == "marketplace/store/marketplace.html"
# ============================================================================
# LETZSHOP PAGE
# ============================================================================
@pytest.mark.unit
@pytest.mark.marketplace
@pytest.mark.asyncio
class TestLetzshopPageOnboardingGate:
"""Test that letzshop page redirects to onboarding when not completed."""
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_redirects_to_onboarding_when_not_completed(
self, mock_templates, mock_ctx, mock_onboarding_cls, db
):
"""Redirect to onboarding if onboarding not completed."""
mock_svc = MagicMock()
mock_svc.is_completed.return_value = False
mock_onboarding_cls.return_value = mock_svc
user = _make_user_context()
response = await store_letzshop_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert isinstance(response, RedirectResponse)
assert response.status_code == 302
assert "/store/teststore/onboarding" in response.headers["location"]
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
@patch("app.modules.marketplace.routes.pages.store.templates")
async def test_renders_letzshop_when_onboarding_completed(
self, mock_templates, mock_ctx, mock_onboarding_cls, db
):
"""Render letzshop page if onboarding is completed."""
mock_svc = MagicMock()
mock_svc.is_completed.return_value = True
mock_onboarding_cls.return_value = mock_svc
mock_ctx.return_value = {"request": _make_request()}
mock_templates.TemplateResponse.return_value = "rendered"
user = _make_user_context()
response = await store_letzshop_page(
request=_make_request(),
store_code="teststore",
current_user=user,
db=db,
)
assert response == "rendered"
mock_templates.TemplateResponse.assert_called_once()
template_name = mock_templates.TemplateResponse.call_args[0][0]
assert template_name == "marketplace/store/letzshop.html"

View File

@@ -20,7 +20,7 @@
<div>
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configure which menu items are visible for admins and stores on this platform.
Configure which menu items are visible for admins, stores, and merchants on this platform.
</p>
</div>
<span class="px-3 py-1 text-sm font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" x-text="platform?.code?.toUpperCase()"></span>
@@ -52,6 +52,17 @@
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
Store Frontend
</button>
<button
@click="frontendType = 'merchant'; loadPlatformMenuConfig()"
:class="{
'bg-white dark:bg-gray-800 shadow': frontendType === 'merchant',
'text-gray-600 dark:text-gray-400': frontendType !== 'merchant'
}"
class="px-4 py-2 text-sm font-medium rounded-md transition-all"
>
<span x-html="$icon('lightning-bolt', 'w-4 h-4 inline mr-2')"></span>
Merchant Frontend
</button>
</div>
</div>

View File

@@ -181,10 +181,24 @@
<!-- 4. API Client -->
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 5. Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- 5. Alpine.js v3 with CDN fallback -->
<script>
(function() {
var script = document.createElement('script');
script.defer = true;
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js';
script.onerror = function() {
console.warn('Alpine.js CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.defer = true;
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<!-- 6. Login Logic -->
<!-- 6. Store Login Logic -->
<script defer src="{{ url_for('tenancy_static', path='store/js/login.js') }}"></script>
</body>
</html>

View File

@@ -1,60 +1,5 @@
{# app/templates/store/partials/sidebar.html #}
{# Collapsible sidebar sections with localStorage persistence - matching admin pattern #}
{# ============================================================================
REUSABLE MACROS FOR SIDEBAR ITEMS
============================================================================ #}
{# Macro for collapsible section header #}
{% macro section_header(title, section_key, icon=none) %}
<div class="px-6 my-4">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<button
@click="toggleSection('{{ section_key }}')"
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<span class="flex items-center">
{% if icon %}
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2 text-gray-400')"></span>
{% endif %}
{{ title }}
</span>
<span
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
:class="{ 'rotate-180': openSections.{{ section_key }} }"
></span>
</button>
{% endmacro %}
{# Macro for collapsible section content wrapper #}
{% macro section_content(section_key) %}
<ul
x-show="openSections.{{ section_key }}"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="mt-1 overflow-hidden"
>
{{ caller() }}
</ul>
{% endmacro %}
{# Macro for menu item - uses storeCode for dynamic URLs #}
{% macro menu_item(page_id, path, icon, label) %}
<li class="relative px-6 py-3">
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/store/${storeCode}/{{ path }}`">
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
<span class="ml-4">{{ label }}</span>
</a>
</li>
{% endmacro %}
{# Dynamic sidebar driven by menu discovery API - matching merchant/admin pattern #}
{# ============================================================================
SIDEBAR CONTENT (shared between desktop and mobile)
@@ -69,57 +14,94 @@
<span x-text="store?.name || 'Store Portal'"></span>
</a>
<!-- Dashboard (always visible) -->
<!-- Loading skeleton -->
<div x-show="menuLoading" class="mt-6 px-6 space-y-4 animate-pulse">
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
<!-- Dynamic menu from API -->
<div x-show="!menuLoading && menuData" x-cloak>
<template x-for="section in (menuData?.sections || [])" :key="section.id">
<div>
{# Unlabeled sections (e.g. "main" with dashboard) render items directly #}
<template x-if="!section.label">
<ul class="mt-6">
{{ menu_item('dashboard', 'dashboard', 'home', 'Dashboard') }}
{{ menu_item('analytics', 'analytics', 'chart-bar', 'Analytics') }}
<template x-for="item in section.items" :key="item.id">
<li class="relative px-6 py-3">
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
:href="item.url">
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
<span class="ml-4" x-text="item.label"></span>
</a>
</li>
</template>
</ul>
</template>
<!-- Products & Inventory Section -->
{{ section_header('Products & Inventory', 'products', 'cube') }}
{% call section_content('products') %}
{{ menu_item('products', 'products', 'shopping-bag', 'All Products') }}
{{ menu_item('inventory', 'inventory', 'clipboard-list', 'Inventory') }}
{{ menu_item('marketplace', 'marketplace', 'download', 'Marketplace Import') }}
{% endcall %}
{# Labeled sections are collapsible #}
<template x-if="section.label">
<div>
<div class="px-6 my-4">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<button
@click="toggleSection(section.id)"
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<span class="flex items-center" x-text="section.label"></span>
<span
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
:class="{ 'rotate-180': openSections[section.id] }"
></span>
</button>
<ul
x-show="openSections[section.id]"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="mt-1 overflow-hidden"
>
<template x-for="item in section.items" :key="item.id">
<li class="relative px-6 py-3">
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
:href="item.url">
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
<span class="ml-4" x-text="item.label"></span>
</a>
</li>
</template>
</ul>
</div>
</template>
</div>
</template>
</div>
<!-- Sales & Orders Section -->
{{ section_header('Sales & Orders', 'sales', 'shopping-cart') }}
{% call section_content('sales') %}
{{ menu_item('orders', 'orders', 'document-text', 'Orders') }}
{{ menu_item('letzshop', 'letzshop', 'external-link', 'Letzshop Orders') }}
{{ menu_item('invoices', 'invoices', 'currency-euro', 'Invoices') }}
{% endcall %}
<!-- Fallback: static dashboard link (if menu API fails) -->
<div x-show="!menuLoading && !menuData" x-cloak>
<ul class="mt-6">
<li class="relative px-6 py-3">
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
:href="`/store/${storeCode}/dashboard`">
<span x-html="$icon('home', 'w-5 h-5')"></span>
<span class="ml-4">Dashboard</span>
</a>
</li>
</ul>
</div>
<!-- Customers & Communication Section -->
{{ section_header('Customers', 'customers', 'users') }}
{% call section_content('customers') %}
{{ menu_item('customers', 'customers', 'user-group', 'All Customers') }}
{{ menu_item('messages', 'messages', 'chat-bubble-left-right', 'Messages') }}
{{ menu_item('notifications', 'notifications', 'bell', 'Notifications') }}
{% endcall %}
<!-- Shop & Content Section -->
{{ section_header('Shop & Content', 'shop', 'color-swatch') }}
{% call section_content('shop') %}
{{ menu_item('content-pages', 'content-pages', 'document-text', 'Content Pages') }}
{{ menu_item('media', 'media', 'photograph', 'Media Library') }}
{# Future: Theme customization, if enabled for store tier
{{ menu_item('theme', 'theme', 'paint-brush', 'Theme') }}
#}
{% endcall %}
<!-- Account & Settings Section -->
{{ section_header('Account & Settings', 'account', 'cog') }}
{% call section_content('account') %}
{{ menu_item('team', 'team', 'user-group', 'Team') }}
{{ menu_item('profile', 'profile', 'user', 'Profile') }}
{{ menu_item('billing', 'billing', 'credit-card', 'Billing') }}
{{ menu_item('email-templates', 'email-templates', 'mail', 'Email Templates') }}
{{ menu_item('settings', 'settings', 'adjustments', 'Settings') }}
{% endcall %}
<!-- Quick Actions -->
<!-- Quick Actions (static, outside dynamic menu) -->
<div class="px-6 my-6">
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@click="$dispatch('open-add-product-modal')">