Compare commits

..

10 Commits

Author SHA1 Message Date
506171503d 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>
2026-02-23 02:14:42 +01:00
be248222bc 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>
2026-02-23 00:24:11 +01:00
716a4e3d15 fix: sidebar highlight on detail pages with numeric IDs
When visiting /merchants/billing/subscriptions/3, currentPage was set
to '3' instead of 'subscriptions'. Now skips numeric trailing segments
so the parent page stays highlighted. Applied to both merchant and
store init-alpine.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:08:17 +01:00
467b1510f4 fix: use apiClient instead of httponly cookie in merchant stores/profile pages
The merchant_token cookie is httponly, so JS cannot read it via
document.cookie. This caused getToken() to return null, redirecting
users to login, which then bounced back to dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:53:05 +01:00
5c8fbd21c7 fix: guard LogConfig access in merchant and store init-alpine.js
log-config.js loads with defer but init-alpine.js runs synchronously,
so window.LogConfig is undefined when init-alpine.js executes. The
crash prevented the Alpine data() function from registering, which
broke auth and caused all merchant pages to 302-redirect to login.

Fall back to console.log when LogConfig is not yet available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:13:30 +01:00
1f3042547b fix: correct billing history sidebar page_id for menu highlight
The page_id was 'billing' but the URL /merchants/billing/invoices
yields currentPage='invoices' from the last URL segment. Change
page_id to 'invoices' so the highlight matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:48:26 +01:00
d7a383f3d7 test: add tests for merchant dashboard metrics and fix invoice template location
Move invoice PDF template from app/templates/invoices/ to
app/modules/orders/templates/invoices/ where InvoicePDFService expects it.
Expand invoice PDF tests to validate template path and existence.

Add unit tests for get_merchant_metrics() in tenancy, billing, and
customer metrics providers. Add unit tests for StatsAggregatorService
merchant methods. Add integration tests for the merchant dashboard
stats endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:46:34 +01:00
b77952bf89 fix: correct billing history menu link in merchant sidebar
The sidebar pointed to /merchants/billing/billing (404) instead of
/merchants/billing/invoices which is the actual page route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:33:05 +01:00
ff852f1ab3 fix: use metrics provider pattern for merchant dashboard stats
The merchant dashboard was showing subscription count as "Total Stores".
Add get_merchant_metrics() to MetricsProviderProtocol and implement it
in tenancy, billing, and customer providers. Dashboard now fetches real
stats from a new /merchants/core/dashboard/stats endpoint and displays
4 cards: active subscriptions, total stores, customers, team members.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:28:59 +01:00
42b894094a feat: add single endpoint for merchant subscriptions with usage data
Replace N+1 per-platform API calls on merchant detail page with a single
GET /admin/subscriptions/merchants/{id} endpoint. Extract shared
subscription+usage aggregation logic into a reusable service method and
refactor the store endpoint to use it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:58:02 +01:00
47 changed files with 3872 additions and 382 deletions

View File

@@ -165,6 +165,10 @@ billing_module = ModuleDefinition(
"billing", # Store billing dashboard
"invoices", # Store invoice history
],
FrontendType.MERCHANT: [
"subscriptions", # Merchant subscriptions
"invoices", # Merchant billing history
],
},
# New module-driven menu definitions
menus={
@@ -199,6 +203,31 @@ billing_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="billing",
label_key="billing.menu.billing_subscriptions",
icon="credit-card",
order=50,
items=[
MenuItemDefinition(
id="subscriptions",
label_key="billing.menu.subscriptions",
icon="clipboard-list",
route="/merchants/billing/subscriptions",
order=10,
is_mandatory=True,
),
MenuItemDefinition(
id="invoices",
label_key="billing.menu.billing_history",
icon="currency-euro",
route="/merchants/billing/invoices",
order=20,
),
],
),
],
FrontendType.STORE: [
MenuSectionDefinition(
id="sales",

View File

@@ -176,6 +176,19 @@ def list_merchant_subscriptions(
)
@admin_router.get("/merchants/{merchant_id}")
def get_merchant_subscriptions(
merchant_id: int = Path(..., description="Merchant ID"),
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get all subscriptions for a merchant with tier info and feature usage."""
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
db, merchant_id
)
return {"subscriptions": results}
@admin_router.post(
"/merchants/{merchant_id}/platforms/{platform_id}",
response_model=MerchantSubscriptionAdminResponse,
@@ -273,53 +286,14 @@ def get_subscription_for_store(
"""
from app.modules.billing.services.feature_service import feature_service
# Resolve store to merchant + all platform IDs
# Resolve store to merchant
merchant_id, platform_ids = feature_service._get_merchant_and_platforms_for_store(db, store_id)
if merchant_id is None or not platform_ids:
raise ResourceNotFoundException("Store", str(store_id))
platforms_map = admin_subscription_service.get_platform_names_map(db)
results = []
for pid in platform_ids:
try:
sub, merchant = admin_subscription_service.get_subscription(db, merchant_id, pid)
except ResourceNotFoundException:
continue
# Get feature summary
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, pid)
# Build tier info
tier_info = None
if sub.tier:
tier_info = {
"code": sub.tier.code,
"name": sub.tier.name,
"feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])],
}
# Build usage metrics (quantitative features only)
usage_metrics = []
for fs in features_summary:
if fs.feature_type == "quantitative" and fs.enabled:
usage_metrics.append({
"name": fs.name_key.replace("_", " ").title(),
"current": fs.current or 0,
"limit": fs.limit,
"percentage": fs.percent_used or 0,
"is_unlimited": fs.limit is None,
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
"is_approaching_limit": (fs.percent_used or 0) >= 80,
})
results.append({
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
"tier": tier_info,
"features": usage_metrics,
"platform_name": platforms_map.get(pid, ""),
})
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
db, merchant_id
)
return {"subscriptions": results}

View File

@@ -286,6 +286,70 @@ class AdminSubscriptionService:
p = db.query(Platform).filter(Platform.id == platform_id).first()
return p.name if p else None
# =========================================================================
# Merchant Subscriptions with Usage
# =========================================================================
def get_merchant_subscriptions_with_usage(
self, db: Session, merchant_id: int
) -> list[dict]:
"""Get all subscriptions for a merchant with tier info and feature usage.
Returns a list of dicts, each containing:
- subscription: serialized MerchantSubscription
- tier: tier info dict (code, name, feature_codes)
- features: list of quantitative usage metrics
- platform_id: int
- platform_name: str
"""
from app.modules.billing.schemas import MerchantSubscriptionAdminResponse
from app.modules.billing.services.feature_service import feature_service
from app.modules.billing.services.subscription_service import (
subscription_service,
)
subs = subscription_service.get_merchant_subscriptions(db, merchant_id)
platforms_map = self.get_platform_names_map(db)
results = []
for sub in subs:
features_summary = feature_service.get_merchant_features_summary(
db, merchant_id, sub.platform_id
)
tier_info = None
if sub.tier:
tier_info = {
"code": sub.tier.code,
"name": sub.tier.name,
"feature_codes": [
fl.feature_code for fl in (sub.tier.feature_limits or [])
],
}
usage_metrics = []
for fs in features_summary:
if fs.feature_type == "quantitative" and fs.enabled:
usage_metrics.append({
"name": fs.name_key.replace("_", " ").title(),
"current": fs.current or 0,
"limit": fs.limit,
"percentage": fs.percent_used or 0,
"is_unlimited": fs.limit is None,
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
"is_approaching_limit": (fs.percent_used or 0) >= 80,
})
results.append({
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
"tier": tier_info,
"features": usage_metrics,
"platform_id": sub.platform_id,
"platform_name": platforms_map.get(sub.platform_id, ""),
})
return results
# =========================================================================
# Statistics
# =========================================================================

View File

@@ -108,6 +108,45 @@ class BillingMetricsProvider:
logger.warning(f"Failed to get billing platform metrics: {e}")
return []
def get_merchant_metrics(
self,
db: Session,
merchant_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get subscription metrics for a specific merchant.
Provides:
- Active subscriptions (active + trial)
"""
from app.modules.billing.models import MerchantSubscription
try:
active_subs = (
db.query(func.count(MerchantSubscription.id))
.filter(
MerchantSubscription.merchant_id == merchant_id,
MerchantSubscription.status.in_(["active", "trial"]),
)
.scalar()
or 0
)
return [
MetricValue(
key="billing.active_subscriptions",
value=active_subs,
label="Active Subscriptions",
category="billing",
icon="clipboard-list",
description="Active or trial subscriptions for this merchant",
),
]
except Exception as e:
logger.warning(f"Failed to get billing merchant metrics: {e}")
return []
# Singleton instance
billing_metrics_provider = BillingMetricsProvider()

View File

@@ -146,7 +146,8 @@ class SubscriptionService:
return (
db.query(MerchantSubscription)
.options(
joinedload(MerchantSubscription.tier),
joinedload(MerchantSubscription.tier)
.joinedload(SubscriptionTier.feature_limits),
joinedload(MerchantSubscription.platform),
)
.filter(MerchantSubscription.merchant_id == merchant_id)

View File

@@ -0,0 +1,149 @@
# app/modules/billing/tests/unit/test_billing_metrics.py
"""Unit tests for BillingMetricsProvider.get_merchant_metrics."""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.billing.models import (
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.billing.services.billing_metrics import BillingMetricsProvider
from app.modules.tenancy.models import Merchant, Platform, User
@pytest.fixture
def billing_platform(db):
"""Create a platform for billing metrics tests."""
platform = Platform(
code=f"bm_{uuid.uuid4().hex[:8]}",
name="Billing Metrics Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def billing_merchant(db):
"""Create a merchant for billing metrics tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"billowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"billowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.flush()
merchant = Merchant(
name="Billing Metrics Merchant",
owner_user_id=user.id,
contact_email=user.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def billing_tier(db, billing_platform):
"""Create a subscription tier."""
tier = SubscriptionTier(
code="professional",
name="Professional",
description="Pro tier",
price_monthly_cents=2900,
price_annual_cents=29000,
display_order=1,
is_active=True,
is_public=True,
platform_id=billing_platform.id,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@pytest.fixture
def billing_extra_platforms(db):
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
platforms = []
for i in range(2):
p = Platform(
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
name=f"Extra Platform {i}",
is_active=True,
)
db.add(p)
platforms.append(p)
db.commit()
for p in platforms:
db.refresh(p)
return platforms
@pytest.fixture
def billing_subscriptions(db, billing_merchant, billing_platform, billing_tier, billing_extra_platforms):
"""Create subscriptions: 1 active, 1 trial, 1 cancelled (each on a different platform)."""
platforms = [billing_platform, billing_extra_platforms[0], billing_extra_platforms[1]]
subs = []
for status, platform in zip(
[SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIAL, SubscriptionStatus.CANCELLED],
platforms, strict=False,
):
sub = MerchantSubscription(
merchant_id=billing_merchant.id,
platform_id=platform.id,
tier_id=billing_tier.id,
status=status.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub)
subs.append(sub)
db.commit()
for s in subs:
db.refresh(s)
return subs
@pytest.mark.unit
@pytest.mark.billing
class TestBillingMetricsProviderMerchant:
"""Tests for BillingMetricsProvider.get_merchant_metrics."""
def setup_method(self):
self.provider = BillingMetricsProvider()
def test_active_subscriptions_count(self, db, billing_merchant, billing_subscriptions):
"""Counts active + trial subscriptions, excludes cancelled."""
metrics = self.provider.get_merchant_metrics(db, billing_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["billing.active_subscriptions"] == 2
def test_no_subscriptions(self, db, billing_merchant):
"""Returns zero when merchant has no subscriptions."""
metrics = self.provider.get_merchant_metrics(db, billing_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["billing.active_subscriptions"] == 0
def test_nonexistent_merchant(self, db):
"""Returns zero for a non-existent merchant ID."""
metrics = self.provider.get_merchant_metrics(db, 999999)
by_key = {m.key: m.value for m in metrics}
assert by_key["billing.active_subscriptions"] == 0

View File

@@ -207,6 +207,28 @@ class MetricsProviderProtocol(Protocol):
"""
...
def get_merchant_metrics(
self,
db: "Session",
merchant_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get metrics scoped to a specific merchant.
Called by the merchant dashboard to display merchant-scoped statistics.
Should only include data belonging to the specified merchant.
Args:
db: Database session for queries
merchant_id: ID of the merchant to get metrics for
context: Optional filtering/scoping context
Returns:
List of MetricValue objects for this merchant
"""
...
__all__ = [
"MetricValue",

View File

@@ -74,6 +74,9 @@ core_module = ModuleDefinition(
"settings",
"email-templates",
],
FrontendType.MERCHANT: [
"dashboard",
],
},
# New module-driven menu definitions
menus={
@@ -121,6 +124,25 @@ core_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="main",
label_key=None,
icon=None,
order=0,
is_collapsible=False,
items=[
MenuItemDefinition(
id="dashboard",
label_key="core.menu.dashboard",
icon="home",
route="/merchants/dashboard",
order=10,
is_mandatory=True,
),
],
),
],
FrontendType.STORE: [
MenuSectionDefinition(
id="main",

View File

@@ -0,0 +1,24 @@
# app/modules/core/routes/api/merchant.py
"""
Core module merchant API routes.
Auto-discovered by the route system (merchant.py in routes/api/ triggers
registration under /api/v1/merchants/core/*).
Aggregates:
- /dashboard/* - Merchant dashboard statistics
"""
from fastapi import APIRouter
from .merchant_dashboard import merchant_dashboard_router
from .merchant_menu import merchant_menu_router
ROUTE_CONFIG = {
"prefix": "/core",
}
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,58 @@
# app/modules/core/routes/api/merchant_dashboard.py
"""
Merchant dashboard statistics endpoint.
Merchant Context: Uses get_merchant_for_current_user to resolve the merchant
from the JWT token. The dependency guarantees an active merchant exists.
This module uses the StatsAggregator service from core to collect metrics from all
enabled modules via the MetricsProvider protocol.
"""
import logging
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import get_merchant_for_current_user
from app.core.database import get_db
from app.modules.core.schemas.dashboard import MerchantDashboardStatsResponse
from app.modules.core.services.stats_aggregator import stats_aggregator
merchant_dashboard_router = APIRouter(prefix="/dashboard")
logger = logging.getLogger(__name__)
@merchant_dashboard_router.get("/stats", response_model=MerchantDashboardStatsResponse)
def get_merchant_dashboard_stats(
request: Request,
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Get merchant-scoped dashboard statistics.
Returns aggregated statistics for the current merchant:
- Active subscriptions
- Total stores
- Total customers across all stores
- Team members across all stores
Merchant is resolved from the JWT token.
Requires Authorization header (API endpoint).
"""
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
flat = stats_aggregator.get_merchant_stats_flat(
db=db,
merchant_id=merchant.id,
platform_id=platform_id,
)
return MerchantDashboardStatsResponse(
active_subscriptions=int(flat.get("billing.active_subscriptions", 0)),
total_stores=int(flat.get("tenancy.total_stores", 0)),
total_customers=int(flat.get("customers.total", 0)),
team_members=int(flat.get("tenancy.team_members", 0)),
)

View File

@@ -0,0 +1,147 @@
# 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)
# 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,
)
# 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,
)

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

@@ -221,6 +221,23 @@ class StoreDashboardStatsResponse(BaseModel):
revenue: StoreRevenueStats
# ============================================================================
# Merchant Dashboard Statistics
# ============================================================================
class MerchantDashboardStatsResponse(BaseModel):
"""Merchant dashboard statistics response schema.
Used by: GET /api/v1/merchants/core/dashboard/stats
"""
active_subscriptions: int = Field(0, description="Active or trial subscriptions")
total_stores: int = Field(0, description="Total stores owned by this merchant")
total_customers: int = Field(0, description="Total customers across all stores")
team_members: int = Field(0, description="Distinct active team members across stores")
__all__ = [
# Stats responses
"StatsResponse",
@@ -240,4 +257,6 @@ __all__ = [
"StoreRevenueStats",
"StoreInfo",
"StoreDashboardStatsResponse",
# Merchant dashboard
"MerchantDashboardStatsResponse",
]

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,195 @@ 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_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,
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()
)
# =========================================================================

View File

@@ -196,6 +196,68 @@ class StatsAggregatorService:
categorized = self.get_store_dashboard_stats(db, store_id, platform_id, context)
return self._flatten_metrics(categorized)
def get_merchant_dashboard_stats(
self,
db: Session,
merchant_id: int,
platform_id: int,
context: MetricsContext | None = None,
) -> dict[str, list[MetricValue]]:
"""
Get all metrics for a merchant, grouped by category.
Called by the merchant dashboard to display merchant-scoped statistics.
Args:
db: Database session
merchant_id: ID of the merchant to get metrics for
platform_id: Platform ID (for module enablement check)
context: Optional filtering/scoping context
Returns:
Dict mapping category name to list of MetricValue objects
"""
providers = self._get_enabled_providers(db, platform_id)
result: dict[str, list[MetricValue]] = {}
for module, provider in providers:
if not hasattr(provider, "get_merchant_metrics"):
continue
try:
metrics = provider.get_merchant_metrics(db, merchant_id, context)
if metrics:
result[provider.metrics_category] = metrics
except Exception as e:
logger.warning(
f"Failed to get merchant metrics from module {module.code}: {e}"
)
return result
def get_merchant_stats_flat(
self,
db: Session,
merchant_id: int,
platform_id: int,
context: MetricsContext | None = None,
) -> dict[str, Any]:
"""
Get merchant metrics as a flat dictionary.
Args:
db: Database session
merchant_id: ID of the merchant to get metrics for
platform_id: Platform ID (for module enablement check)
context: Optional filtering/scoping context
Returns:
Flat dict mapping metric keys to values
"""
categorized = self.get_merchant_dashboard_stats(
db, merchant_id, platform_id, context
)
return self._flatten_metrics(categorized)
def get_admin_stats_flat(
self,
db: Session,

View File

@@ -4,8 +4,8 @@
* Provides common data and methods for all merchant pages
*/
// Use centralized logger
const merchantLog = window.LogConfig.log;
// Use centralized logger (guarded: log-config.js loads with defer, so it may not be ready yet)
const merchantLog = (window.LogConfig && window.LogConfig.log) || console.log;
console.log('[MERCHANT INIT-ALPINE] Loading...');
@@ -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,13 +58,21 @@ 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;
const segments = path.split('/').filter(Boolean);
// For /merchants/dashboard -> 'dashboard'
// For /merchants/billing/subscriptions -> 'subscriptions'
this.currentPage = segments[segments.length - 1] || 'dashboard';
// For /merchants/billing/subscriptions/3 -> 'subscriptions' (skip numeric IDs)
const last = segments[segments.length - 1] || 'dashboard';
this.currentPage = /^\d+$/.test(last) && segments.length > 2
? segments[segments.length - 2]
: last;
// Load merchant name from JWT token
const token = localStorage.getItem('merchant_token');
@@ -83,6 +101,9 @@ function data() {
// Ignore storage errors
}
}
// Load dynamic menu
this.loadMenuConfig();
},
toggleSideMenu() {
@@ -112,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...');

View File

@@ -4,8 +4,8 @@
* Provides common data and methods for all store pages
*/
// Use centralized logger
const storeLog = window.LogConfig.log;
// Use centralized logger (guarded: log-config.js loads with defer, so it may not be ready yet)
const storeLog = (window.LogConfig && window.LogConfig.log) || console.log;
console.log('[STORE INIT-ALPINE] Loading...');
@@ -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(),
@@ -58,7 +68,11 @@ function data() {
// Set current page from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
this.currentPage = segments[segments.length - 1] || 'dashboard';
// For /store/ABC/orders/123 -> 'orders' (skip numeric IDs)
const last = segments[segments.length - 1] || 'dashboard';
this.currentPage = /^\d+$/.test(last) && segments.length > 2
? segments[segments.length - 2]
: last;
// Get store code from URL
if (segments[0] === 'store' && segments[1]) {
@@ -77,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
@@ -107,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;
},
@@ -267,4 +314,4 @@ function emailSettingsWarning() {
};
}
window.emailSettingsWarning = emailSettingsWarning;
window.emailSettingsWarning = emailSettingsWarning;

View File

@@ -13,7 +13,7 @@
</div>
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-3">
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Active Subscriptions -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
@@ -36,14 +36,25 @@
</div>
</div>
<!-- Current Plan -->
<!-- Customers -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<span x-html="$icon('sparkles', 'w-6 h-6')"></span>
<div class="p-3 mr-4 text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30 rounded-full">
<span x-html="$icon('users', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Current Plan</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.current_plan || '--'">--</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Customers</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.total_customers">--</p>
</div>
</div>
<!-- Team Members -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/30 rounded-full">
<span x-html="$icon('user-group', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Team Members</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.team_members">--</p>
</div>
</div>
</div>
@@ -106,7 +117,8 @@ function merchantDashboard() {
stats: {
active_subscriptions: '--',
total_stores: '--',
current_plan: '--'
total_customers: '--',
team_members: '--'
},
subscriptions: [],
@@ -124,15 +136,19 @@ function merchantDashboard() {
async loadDashboard() {
try {
const data = await apiClient.get('/merchants/billing/subscriptions');
this.subscriptions = data.subscriptions || data.items || [];
const [statsData, subsData] = await Promise.all([
apiClient.get('/merchants/core/dashboard/stats'),
apiClient.get('/merchants/billing/subscriptions'),
]);
const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial');
this.stats.active_subscriptions = active.length;
this.stats.total_stores = this.subscriptions.length;
this.stats.current_plan = active.length > 0
? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1)
: 'None';
// Stats from the aggregated endpoint
this.stats.active_subscriptions = statsData.active_subscriptions ?? 0;
this.stats.total_stores = statsData.total_stores ?? 0;
this.stats.total_customers = statsData.total_customers ?? 0;
this.stats.team_members = statsData.team_members ?? 0;
// Subscriptions list for the overview table
this.subscriptions = subsData.subscriptions || subsData.items || [];
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {

View File

@@ -0,0 +1,241 @@
# app/modules/core/tests/integration/test_merchant_dashboard_routes.py
"""
Integration tests for merchant dashboard API routes.
Tests the merchant dashboard endpoint at:
GET /api/v1/merchants/core/dashboard/stats
"""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
from app.modules.billing.models import (
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.customers.models.customer import Customer
from app.modules.tenancy.models import Merchant, Platform, Store, StoreUser, User
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/merchants/core"
@pytest.fixture
def dash_owner(db):
"""Create a merchant owner user."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"dashowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"dashowner_{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 dash_platform(db):
"""Create a platform."""
platform = Platform(
code=f"dp_{uuid.uuid4().hex[:8]}",
name="Dashboard Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def dash_merchant(db, dash_owner):
"""Create a merchant."""
merchant = Merchant(
name="Dashboard Test Merchant",
owner_user_id=dash_owner.id,
contact_email=dash_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def dash_stores(db, dash_merchant):
"""Create 3 stores (2 active, 1 inactive)."""
stores = []
for i, active in enumerate([True, True, False]):
uid = uuid.uuid4().hex[:8].upper()
store = Store(
merchant_id=dash_merchant.id,
store_code=f"DSTORE_{uid}",
subdomain=f"dstore{uid.lower()}",
name=f"Dashboard Store {i}",
is_active=active,
is_verified=True,
)
db.add(store)
stores.append(store)
db.commit()
for s in stores:
db.refresh(s)
return stores
@pytest.fixture
def dash_team_members(db, dash_stores, dash_owner):
"""Create team members across stores."""
from middleware.auth import AuthManager
auth = AuthManager()
users = []
for _ in range(2):
u = User(
email=f"dteam_{uuid.uuid4().hex[:8]}@test.com",
username=f"dteam_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="store_user",
is_active=True,
)
db.add(u)
users.append(u)
db.flush()
db.add(StoreUser(store_id=dash_stores[0].id, user_id=users[0].id, is_active=True))
db.add(StoreUser(store_id=dash_stores[1].id, user_id=users[1].id, is_active=True))
db.commit()
return users
@pytest.fixture
def dash_customers(db, dash_stores):
"""Create customers in the merchant's stores."""
for i in range(4):
uid = uuid.uuid4().hex[:8]
db.add(
Customer(
store_id=dash_stores[0].id,
email=f"dc_{uid}@test.com",
hashed_password="hashed", # noqa: SEC001
first_name=f"F{i}",
last_name=f"L{i}",
customer_number=f"DC{uid}",
is_active=True,
)
)
db.commit()
@pytest.fixture
def dash_subscription(db, dash_merchant, dash_platform):
"""Create an active subscription."""
tier = SubscriptionTier(
code=f"pro_{uuid.uuid4().hex[:8]}",
name="Professional",
description="Pro",
price_monthly_cents=2900,
price_annual_cents=29000,
display_order=1,
is_active=True,
is_public=True,
platform_id=dash_platform.id,
)
db.add(tier)
db.flush()
sub = MerchantSubscription(
merchant_id=dash_merchant.id,
platform_id=dash_platform.id,
tier_id=tier.id,
status=SubscriptionStatus.ACTIVE.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
@pytest.fixture
def dash_auth(dash_owner, dash_merchant):
"""Override auth dependencies for dashboard merchant."""
user_context = UserContext(
id=dash_owner.id,
email=dash_owner.email,
username=dash_owner.username,
role="merchant_owner",
is_active=True,
)
def _override_merchant():
return dash_merchant
def _override_user():
return user_context
app.dependency_overrides[get_merchant_for_current_user] = _override_merchant
app.dependency_overrides[get_current_merchant_api] = _override_user
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_merchant_for_current_user, None)
app.dependency_overrides.pop(get_current_merchant_api, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestMerchantDashboardStats:
"""Tests for GET /api/v1/merchants/core/dashboard/stats."""
def test_returns_correct_stats(
self, client, db, dash_auth, dash_stores, dash_team_members, dash_customers, dash_subscription
):
"""Endpoint returns correct aggregated stats."""
response = client.get(f"{BASE}/dashboard/stats", headers=dash_auth)
assert response.status_code == 200
data = response.json()
assert data["total_stores"] == 3
assert data["active_subscriptions"] == 1
assert data["total_customers"] == 4
assert data["team_members"] == 2
def test_returns_zeros_when_empty(self, client, db, dash_auth):
"""Returns zero counts when merchant has no data."""
response = client.get(f"{BASE}/dashboard/stats", headers=dash_auth)
assert response.status_code == 200
data = response.json()
assert data["total_stores"] == 0
assert data["active_subscriptions"] == 0
assert data["total_customers"] == 0
assert data["team_members"] == 0
def test_requires_auth(self, client):
"""Returns 401 without auth."""
# Remove any overrides
app.dependency_overrides.pop(get_merchant_for_current_user, None)
app.dependency_overrides.pop(get_current_merchant_api, None)
response = client.get(f"{BASE}/dashboard/stats")
assert response.status_code == 401

View File

@@ -0,0 +1,477 @@
# app/modules/core/tests/integration/test_merchant_menu_routes.py
"""
Integration tests for merchant menu API routes.
Tests the merchant menu rendering endpoint at:
GET /api/v1/merchants/core/menu/render/merchant
Verifies:
- Dynamic menu rendering based on module enablement
- Subscription gating (only modules from subscribed platforms)
- Auth requirement
- Response structure
- Fallback when merchant has no subscriptions
"""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.api.deps import get_current_merchant_from_cookie_or_header
from app.modules.billing.models import (
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.tenancy.models import Merchant, Platform, User
from app.modules.tenancy.models.platform_module import PlatformModule
from main import app
from models.schema.auth import UserContext
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/merchants/core/menu/render/merchant"
@pytest.fixture
def menu_owner(db):
"""Create a merchant owner user."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"menuowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"menuowner_{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 menu_admin(db):
"""Create a super admin for enabling modules."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"menuadmin_{uuid.uuid4().hex[:8]}@test.com",
username=f"menuadmin_{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 menu_platform(db):
"""Create a platform."""
platform = Platform(
code=f"mp_{uuid.uuid4().hex[:8]}",
name="Menu Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def menu_merchant(db, menu_owner):
"""Create a merchant owned by menu_owner."""
merchant = Merchant(
name="Menu Test Merchant",
owner_user_id=menu_owner.id,
contact_email=menu_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def menu_tier(db, menu_platform):
"""Create a subscription tier."""
tier = SubscriptionTier(
code=f"pro_{uuid.uuid4().hex[:8]}",
name="Professional",
description="Pro tier",
price_monthly_cents=2900,
price_annual_cents=29000,
display_order=1,
is_active=True,
is_public=True,
platform_id=menu_platform.id,
)
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@pytest.fixture
def menu_subscription(db, menu_merchant, menu_platform, menu_tier):
"""Create an active subscription for the merchant on the platform."""
sub = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=menu_platform.id,
tier_id=menu_tier.id,
status=SubscriptionStatus.ACTIVE.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
@pytest.fixture
def menu_loyalty_module(db, menu_platform, menu_admin):
"""Enable loyalty module on the platform."""
pm = PlatformModule(
platform_id=menu_platform.id,
module_code="loyalty",
is_enabled=True,
enabled_at=datetime.now(UTC),
enabled_by_user_id=menu_admin.id,
config={},
)
db.add(pm)
db.commit()
db.refresh(pm)
return pm
@pytest.fixture
def menu_auth(menu_owner):
"""Override auth dependency for merchant cookie/header auth."""
user_context = UserContext(
id=menu_owner.id,
email=menu_owner.email,
username=menu_owner.username,
role="merchant_owner",
is_active=True,
)
def _override():
return user_context
app.dependency_overrides[get_current_merchant_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuRendering:
"""Tests for GET /api/v1/merchants/core/menu/render/merchant."""
def test_requires_auth(self, client):
"""Returns 401 without auth."""
app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None)
response = client.get(BASE)
assert response.status_code == 401
def test_returns_valid_structure(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Response has correct top-level structure."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
assert "frontend_type" in data
assert data["frontend_type"] == "merchant"
assert "sections" in data
assert isinstance(data["sections"], list)
def test_returns_core_sections(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Menu includes core sections: main, billing, account."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
def test_main_section_has_dashboard(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Main section contains the dashboard item."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
main = next(s for s in data["sections"] if s["id"] == "main")
item_ids = [i["id"] for i in main["items"]]
assert "dashboard" in item_ids
def test_section_items_have_required_fields(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Each menu item has id, label, icon, and url."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
for section in data["sections"]:
for item in section["items"]:
assert "id" in item
assert "label" in item
assert "icon" in item
assert "url" in item
assert item["label"] is not None
assert item["url"].startswith("/merchants/")
def test_sections_have_correct_labels(
self, client, db, menu_auth, menu_merchant, menu_subscription
):
"""Labeled sections have non-null labels, main section has null label."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
for section in data["sections"]:
if section["id"] == "main":
assert section["label"] is None
else:
assert section["label"] is not None
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuModuleGating:
"""Tests for module-based menu filtering."""
def test_loyalty_appears_when_module_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
):
"""Loyalty section appears when loyalty module is enabled on subscribed platform."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" in section_ids
def test_loyalty_hidden_when_module_not_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
):
"""Loyalty section is hidden when loyalty module is NOT enabled."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" not in section_ids
def test_loyalty_item_has_correct_route(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
):
"""Loyalty overview item has the correct URL."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
loyalty = next(s for s in data["sections"] if s["id"] == "loyalty")
overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview")
assert overview["url"] == "/merchants/loyalty/overview"
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuNoSubscription:
"""Tests for menu when merchant has no subscriptions."""
def test_empty_menu_when_no_merchant(self, client, db, menu_auth):
"""Returns empty sections when user has no merchant."""
# menu_auth provides a user, but no merchant fixture is loaded
# The endpoint queries for merchant by owner_user_id — none exists for this user
# unless menu_merchant is requested
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
assert data["frontend_type"] == "merchant"
assert data["sections"] == []
def test_core_sections_with_merchant_no_subscription(
self, client, db, menu_auth, menu_merchant,
):
"""With merchant but no subscription, core sections still appear."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
# Core modules (core, billing, tenancy) are always included
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
# Non-core modules require subscription
assert "loyalty" not in section_ids
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuSubscriptionStatus:
"""Tests for subscription status filtering."""
def test_trial_subscription_shows_modules(
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
menu_loyalty_module,
):
"""Trial subscription shows modules from that platform."""
sub = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=menu_platform.id,
tier_id=menu_tier.id,
status=SubscriptionStatus.TRIAL.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=14),
trial_ends_at=datetime.now(UTC) + timedelta(days=14),
)
db.add(sub)
db.commit()
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
assert "loyalty" in section_ids
def test_expired_subscription_hides_non_core_modules(
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
menu_loyalty_module,
):
"""Expired subscription does not show modules from that platform."""
sub = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=menu_platform.id,
tier_id=menu_tier.id,
status=SubscriptionStatus.EXPIRED.value,
is_annual=False,
period_start=datetime.now(UTC) - timedelta(days=60),
period_end=datetime.now(UTC) - timedelta(days=30),
)
db.add(sub)
db.commit()
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty should NOT appear because subscription is expired
assert "loyalty" not in section_ids
# Core sections always appear
assert "main" in section_ids
assert "billing" in section_ids
@pytest.mark.integration
@pytest.mark.core
class TestMerchantMenuMultiPlatform:
"""Tests for merchants subscribed to multiple platforms."""
def test_union_of_modules_from_multiple_platforms(
self, client, db, menu_auth, menu_merchant, menu_admin,
):
"""Modules enabled on different platforms are unioned together."""
# Platform A with loyalty enabled
platform_a = Platform(
code=f"mpa_{uuid.uuid4().hex[:8]}",
name="Platform A",
is_active=True,
)
db.add(platform_a)
db.flush()
tier_a = SubscriptionTier(
code=f"ta_{uuid.uuid4().hex[:8]}",
name="Tier A",
description="T",
price_monthly_cents=1000,
display_order=1,
is_active=True,
is_public=True,
platform_id=platform_a.id,
)
db.add(tier_a)
db.flush()
db.add(PlatformModule(
platform_id=platform_a.id,
module_code="loyalty",
is_enabled=True,
enabled_at=datetime.now(UTC),
enabled_by_user_id=menu_admin.id,
config={},
))
sub_a = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=platform_a.id,
tier_id=tier_a.id,
status=SubscriptionStatus.ACTIVE.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub_a)
# Platform B without loyalty
platform_b = Platform(
code=f"mpb_{uuid.uuid4().hex[:8]}",
name="Platform B",
is_active=True,
)
db.add(platform_b)
db.flush()
tier_b = SubscriptionTier(
code=f"tb_{uuid.uuid4().hex[:8]}",
name="Tier B",
description="T",
price_monthly_cents=1000,
display_order=1,
is_active=True,
is_public=True,
platform_id=platform_b.id,
)
db.add(tier_b)
db.flush()
sub_b = MerchantSubscription(
merchant_id=menu_merchant.id,
platform_id=platform_b.id,
tier_id=tier_b.id,
status=SubscriptionStatus.ACTIVE.value,
is_annual=False,
period_start=datetime.now(UTC),
period_end=datetime.now(UTC) + timedelta(days=30),
)
db.add(sub_b)
db.commit()
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty enabled on Platform A should appear in the union
assert "loyalty" in section_ids
# Core sections always present
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids

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

@@ -3,6 +3,7 @@
import pytest
from app.modules.core.services.menu_discovery_service import MenuDiscoveryService
from app.modules.enums import FrontendType
@pytest.mark.unit
@@ -16,3 +17,165 @@ class TestMenuDiscoveryService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
def test_discover_all_menus_includes_merchant(self):
"""discover_all_menus() includes FrontendType.MERCHANT entries."""
menus = self.service.discover_all_menus()
assert FrontendType.MERCHANT in menus
merchant_sections = menus[FrontendType.MERCHANT]
assert len(merchant_sections) > 0, "Expected at least one MERCHANT menu section"
def test_merchant_menu_has_expected_section_ids(self):
"""MERCHANT menus contain main, billing, account, and loyalty sections."""
menus = self.service.discover_all_menus()
section_ids = {s.id for s in menus[FrontendType.MERCHANT]}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" in section_ids
def test_merchant_main_section_has_dashboard(self):
"""Main section contains the dashboard item."""
menus = self.service.discover_all_menus()
main_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "main"]
assert len(main_sections) == 1
item_ids = [i.id for i in main_sections[0].items]
assert "dashboard" in item_ids
def test_merchant_billing_section_items(self):
"""Billing section contains subscriptions and invoices."""
menus = self.service.discover_all_menus()
billing_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "billing"]
assert len(billing_sections) == 1
item_ids = [i.id for i in billing_sections[0].items]
assert "subscriptions" in item_ids
assert "invoices" in item_ids
def test_merchant_account_section_items(self):
"""Account section contains stores and profile."""
menus = self.service.discover_all_menus()
account_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "account"]
assert len(account_sections) == 1
item_ids = [i.id for i in account_sections[0].items]
assert "stores" in item_ids
assert "profile" in item_ids
def test_merchant_loyalty_section_items(self):
"""Loyalty section contains loyalty-overview."""
menus = self.service.discover_all_menus()
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
assert len(loyalty_sections) == 1
item_ids = [i.id for i in loyalty_sections[0].items]
assert "loyalty-overview" in item_ids
def test_get_all_menu_items_merchant(self):
"""get_all_menu_items returns items for MERCHANT frontend type."""
items = self.service.get_all_menu_items(FrontendType.MERCHANT)
assert len(items) > 0
item_ids = {i.id for i in items}
assert "dashboard" in item_ids
assert "subscriptions" in item_ids
assert "loyalty-overview" in item_ids
def test_get_all_menu_item_ids_merchant(self):
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
item_ids = self.service.get_all_menu_item_ids(FrontendType.MERCHANT)
assert "dashboard" in item_ids
assert "subscriptions" in item_ids
assert "invoices" in item_ids
assert "stores" in item_ids
assert "profile" in item_ids
assert "loyalty-overview" in item_ids
def test_get_mandatory_item_ids_merchant(self):
"""Mandatory items for MERCHANT include dashboard and subscriptions."""
mandatory = self.service.get_mandatory_item_ids(FrontendType.MERCHANT)
assert "dashboard" in mandatory
assert "subscriptions" in mandatory
def test_merchant_sections_have_expected_orders(self):
"""MERCHANT menu sections have the expected order values."""
menus = self.service.discover_all_menus()
sections = menus[FrontendType.MERCHANT]
order_map = {s.id: s.order for s in sections}
# main is lowest, account is highest
assert order_map["main"] < order_map["billing"]
assert order_map["billing"] < order_map["loyalty"]
assert order_map["loyalty"] < order_map["account"]
@pytest.mark.unit
@pytest.mark.core
class TestMenuDiscoveryServiceEnabledModuleCodes:
"""Test enabled_module_codes parameter on get_menu_sections_for_frontend."""
def setup_method(self):
self.service = MenuDiscoveryService()
def test_all_modules_enabled_shows_all_sections(self, db):
"""When all module codes are provided, all sections appear."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_sections_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" in section_ids
def test_loyalty_disabled_hides_loyalty_section(self, db):
"""When loyalty is not in enabled_module_codes, its section still appears
but items are marked as disabled (filtered out by get_menu_for_frontend)."""
enabled = {"core", "billing", "tenancy"}
sections = self.service.get_menu_sections_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
# Sections still exist, but loyalty items are marked disabled
loyalty_sections = [s for s in sections if s.id == "loyalty"]
if loyalty_sections:
for item in loyalty_sections[0].items:
assert item.is_module_enabled is False
def test_get_menu_for_frontend_filters_disabled_modules(self, db):
"""get_menu_for_frontend removes items from disabled modules."""
enabled = {"core", "billing", "tenancy"} # No loyalty
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" not in section_ids, "Loyalty section should be filtered out"
def test_get_menu_for_frontend_includes_loyalty_when_enabled(self, db):
"""get_menu_for_frontend includes loyalty when its module is enabled."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "loyalty" in section_ids
def test_core_only_shows_minimal_sections(self, db):
"""With only core modules, only main section appears."""
enabled = {"core"}
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" not in section_ids
assert "loyalty" not in section_ids
def test_enabled_module_codes_none_shows_all(self, db):
"""When enabled_module_codes is None and no platform_id, all modules shown."""
sections = self.service.get_menu_for_frontend(
db, FrontendType.MERCHANT, enabled_module_codes=None,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "loyalty" in section_ids
assert "account" in section_ids

View File

@@ -3,6 +3,7 @@
import pytest
from app.modules.core.services.menu_service import MenuService
from app.modules.enums import FrontendType
@pytest.mark.unit
@@ -16,3 +17,96 @@ class TestMenuService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
@pytest.mark.unit
@pytest.mark.core
class TestMenuServiceMerchantRendering:
"""Test get_menu_for_rendering with FrontendType.MERCHANT and enabled_module_codes."""
def setup_method(self):
self.service = MenuService()
def test_render_merchant_menu_all_modules(self, db):
"""Rendering with all modules shows all sections."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "main" in section_ids
assert "billing" in section_ids
assert "account" in section_ids
assert "loyalty" in section_ids
def test_render_merchant_menu_without_loyalty(self, db):
"""Rendering without loyalty module hides loyalty section."""
enabled = {"core", "billing", "tenancy"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
section_ids = {s.id for s in sections}
assert "loyalty" not in section_ids
assert "main" in section_ids
assert "billing" in section_ids
def test_render_merchant_menu_section_item_structure(self, db):
"""Each section has items with expected attributes."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
for section in sections:
assert section.id
assert len(section.items) > 0
for item in section.items:
assert item.id
assert item.label_key
assert item.icon
assert item.route
assert item.route.startswith("/merchants/")
def test_render_merchant_menu_section_ordering(self, db):
"""Sections are returned in correct order."""
enabled = {"core", "billing", "tenancy", "loyalty"}
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=enabled,
)
orders = [s.order for s in sections]
assert orders == sorted(orders)
def test_render_merchant_menu_no_enabled_modules_returns_nothing(self, db):
"""With empty enabled set, no sections are returned."""
sections = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes=set(),
)
assert len(sections) == 0
def test_enabled_module_codes_propagated(self, db):
"""enabled_module_codes parameter is properly propagated to discovery service."""
# With loyalty
with_loyalty = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes={"core", "billing", "tenancy", "loyalty"},
)
# Without loyalty
without_loyalty = self.service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
enabled_module_codes={"core", "billing", "tenancy"},
)
with_ids = {s.id for s in with_loyalty}
without_ids = {s.id for s in without_loyalty}
assert "loyalty" in with_ids
assert "loyalty" not in without_ids

View File

@@ -0,0 +1,106 @@
# app/modules/core/tests/unit/test_stats_aggregator.py
"""Unit tests for StatsAggregatorService merchant methods."""
from unittest.mock import MagicMock, patch
import pytest
from app.modules.contracts.metrics import MetricValue
from app.modules.core.services.stats_aggregator import StatsAggregatorService
def _make_metric(key: str, value: int, category: str = "test") -> MetricValue:
return MetricValue(key=key, value=value, label=key, category=category)
@pytest.mark.unit
@pytest.mark.core
class TestStatsAggregatorMerchant:
"""Tests for StatsAggregatorService merchant aggregation methods."""
def setup_method(self):
self.service = StatsAggregatorService()
def test_get_merchant_dashboard_stats_aggregates_providers(self, db):
"""Aggregates metrics from providers that implement get_merchant_metrics."""
provider_a = MagicMock()
provider_a.metrics_category = "billing"
provider_a.get_merchant_metrics.return_value = [
_make_metric("billing.active_subscriptions", 2, "billing"),
]
provider_b = MagicMock()
provider_b.metrics_category = "tenancy"
provider_b.get_merchant_metrics.return_value = [
_make_metric("tenancy.total_stores", 3, "tenancy"),
]
module_a = MagicMock()
module_a.code = "billing"
module_b = MagicMock()
module_b.code = "tenancy"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module_a, provider_a), (module_b, provider_b)]
):
result = self.service.get_merchant_dashboard_stats(db, merchant_id=1, platform_id=1)
assert "billing" in result
assert "tenancy" in result
assert result["billing"][0].value == 2
assert result["tenancy"][0].value == 3
def test_get_merchant_dashboard_stats_skips_providers_without_method(self, db):
"""Skips providers that don't have get_merchant_metrics."""
provider = MagicMock(spec=[]) # No attributes at all
provider.metrics_category = "legacy"
module = MagicMock()
module.code = "legacy"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
result = self.service.get_merchant_dashboard_stats(db, merchant_id=1, platform_id=1)
assert result == {}
def test_get_merchant_dashboard_stats_handles_provider_error(self, db):
"""Gracefully handles a provider raising an exception."""
provider = MagicMock()
provider.metrics_category = "broken"
provider.get_merchant_metrics.side_effect = RuntimeError("DB error")
module = MagicMock()
module.code = "broken"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
result = self.service.get_merchant_dashboard_stats(db, merchant_id=1, platform_id=1)
assert result == {}
def test_get_merchant_stats_flat(self, db):
"""Flattens categorized metrics into a single dict."""
provider = MagicMock()
provider.metrics_category = "tenancy"
provider.get_merchant_metrics.return_value = [
_make_metric("tenancy.total_stores", 5, "tenancy"),
_make_metric("tenancy.team_members", 12, "tenancy"),
]
module = MagicMock()
module.code = "tenancy"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
flat = self.service.get_merchant_stats_flat(db, merchant_id=1, platform_id=1)
assert flat["tenancy.total_stores"] == 5
assert flat["tenancy.team_members"] == 12
def test_get_merchant_stats_flat_empty(self, db):
"""Returns empty dict when no providers have merchant metrics."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
flat = self.service.get_merchant_stats_flat(db, merchant_id=1, platform_id=1)
assert flat == {}

View File

@@ -196,6 +196,47 @@ class CustomerMetricsProvider:
logger.warning(f"Failed to get customer platform metrics: {e}")
return []
def get_merchant_metrics(
self,
db: Session,
merchant_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get customer metrics scoped to a merchant.
Aggregates customer counts across all stores owned by the merchant.
"""
from app.modules.customers.models import Customer
from app.modules.tenancy.models import Store
try:
merchant_store_ids = (
db.query(Store.id)
.filter(Store.merchant_id == merchant_id)
.subquery()
)
total_customers = (
db.query(Customer)
.filter(Customer.store_id.in_(merchant_store_ids))
.count()
)
return [
MetricValue(
key="customers.total",
value=total_customers,
label="Total Customers",
category="customers",
icon="users",
description="Total customers across all merchant stores",
),
]
except Exception as e:
logger.warning(f"Failed to get customer merchant metrics: {e}")
return []
# Singleton instance
customer_metrics_provider = CustomerMetricsProvider()

View File

@@ -0,0 +1,130 @@
# app/modules/customers/tests/unit/test_customer_metrics.py
"""Unit tests for CustomerMetricsProvider.get_merchant_metrics."""
import uuid
import pytest
from app.modules.customers.models.customer import Customer
from app.modules.customers.services.customer_metrics import CustomerMetricsProvider
from app.modules.tenancy.models import Merchant, Store, User
@pytest.fixture
def cust_merchant(db):
"""Create a merchant for customer metrics tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"custowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"custowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.flush()
merchant = Merchant(
name="Customer Metrics Merchant",
owner_user_id=user.id,
contact_email=user.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def cust_stores(db, cust_merchant):
"""Create 2 stores for the merchant."""
stores = []
for i in range(2):
uid = uuid.uuid4().hex[:8].upper()
store = Store(
merchant_id=cust_merchant.id,
store_code=f"CSTORE_{uid}",
subdomain=f"cstore{uid.lower()}",
name=f"Cust Store {i}",
is_active=True,
is_verified=True,
)
db.add(store)
stores.append(store)
db.commit()
for s in stores:
db.refresh(s)
return stores
@pytest.fixture
def cust_customers(db, cust_stores):
"""Create customers across the merchant's stores."""
customers = []
# 3 customers in store 0
for i in range(3):
uid = uuid.uuid4().hex[:8]
c = Customer(
store_id=cust_stores[0].id,
email=f"cust_{uid}@test.com",
hashed_password="hashed", # noqa: SEC001
first_name=f"First{i}",
last_name=f"Last{i}",
customer_number=f"C{uid}",
is_active=True,
)
db.add(c)
customers.append(c)
# 2 customers in store 1
for i in range(2):
uid = uuid.uuid4().hex[:8]
c = Customer(
store_id=cust_stores[1].id,
email=f"cust_{uid}@test.com",
hashed_password="hashed", # noqa: SEC001
first_name=f"First{i}",
last_name=f"Last{i}",
customer_number=f"C{uid}",
is_active=True,
)
db.add(c)
customers.append(c)
db.commit()
return customers
@pytest.mark.unit
@pytest.mark.customers
class TestCustomerMetricsProviderMerchant:
"""Tests for CustomerMetricsProvider.get_merchant_metrics."""
def setup_method(self):
self.provider = CustomerMetricsProvider()
def test_total_customers_across_stores(self, db, cust_merchant, cust_stores, cust_customers):
"""Aggregates customers across all merchant stores."""
metrics = self.provider.get_merchant_metrics(db, cust_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["customers.total"] == 5
def test_no_customers(self, db, cust_merchant, cust_stores):
"""Returns zero when stores have no customers."""
metrics = self.provider.get_merchant_metrics(db, cust_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["customers.total"] == 0
def test_no_stores(self, db, cust_merchant):
"""Returns zero when merchant has no stores."""
metrics = self.provider.get_merchant_metrics(db, cust_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["customers.total"] == 0
def test_nonexistent_merchant(self, db):
"""Returns zero for a non-existent merchant ID."""
metrics = self.provider.get_merchant_metrics(db, 999999)
by_key = {m.key: m.value for m in metrics}
assert by_key["customers.total"] == 0

View File

@@ -117,6 +117,9 @@ loyalty_module = ModuleDefinition(
"loyalty-cards", # Customer cards
"loyalty-stats", # Store stats
],
FrontendType.MERCHANT: [
"loyalty-overview", # Merchant loyalty overview
],
},
# New module-driven menu definitions
menus={
@@ -175,6 +178,23 @@ loyalty_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="loyalty",
label_key="loyalty.menu.loyalty",
icon="gift",
order=60,
items=[
MenuItemDefinition(
id="loyalty-overview",
label_key="loyalty.menu.overview",
icon="gift",
route="/merchants/loyalty/overview",
order=10,
),
],
),
],
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="account",

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

@@ -1,8 +1,13 @@
"""Unit tests for InvoicePDFService."""
from pathlib import Path
import pytest
from app.modules.orders.services.invoice_pdf_service import InvoicePDFService
from app.modules.orders.services.invoice_pdf_service import (
TEMPLATE_DIR,
InvoicePDFService,
)
@pytest.mark.unit
@@ -16,3 +21,25 @@ class TestInvoicePDFService:
def test_service_instantiation(self):
"""Service can be instantiated."""
assert self.service is not None
def test_template_directory_exists(self):
"""Template directory must exist at the expected path."""
assert TEMPLATE_DIR.exists(), f"Template directory missing: {TEMPLATE_DIR}"
assert TEMPLATE_DIR.is_dir()
def test_invoice_template_exists(self):
"""invoice.html template must exist in the template directory."""
template_path = TEMPLATE_DIR / "invoice.html"
assert template_path.exists(), f"Invoice template missing: {template_path}"
def test_template_can_be_loaded(self):
"""Jinja2 environment can load the invoice template."""
template = self.service.env.get_template("invoice.html")
assert template is not None
def test_template_dir_is_inside_orders_module(self):
"""Template directory should be inside the orders module, not the global templates."""
orders_module_dir = Path(__file__).parent.parent.parent
assert str(TEMPLATE_DIR).startswith(str(orders_module_dir)), (
f"TEMPLATE_DIR ({TEMPLATE_DIR}) should be inside orders module ({orders_module_dir})"
)

View File

@@ -91,6 +91,10 @@ tenancy_module = ModuleDefinition(
FrontendType.STORE: [
"team",
],
FrontendType.MERCHANT: [
"stores",
"profile",
],
},
# New module-driven menu definitions
menus={
@@ -160,6 +164,30 @@ tenancy_module = ModuleDefinition(
],
),
],
FrontendType.MERCHANT: [
MenuSectionDefinition(
id="account",
label_key="tenancy.menu.account",
icon="cog",
order=900,
items=[
MenuItemDefinition(
id="stores",
label_key="tenancy.menu.stores",
icon="shopping-bag",
route="/merchants/account/stores",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="tenancy.menu.profile",
icon="user",
route="/merchants/account/profile",
order=20,
),
],
),
],
FrontendType.STORE: [
MenuSectionDefinition(
id="account",

View File

@@ -394,6 +394,84 @@ class TenancyMetricsProvider:
logger.warning(f"Failed to get tenancy platform metrics: {e}")
return []
def get_merchant_metrics(
self,
db: Session,
merchant_id: int,
context: MetricsContext | None = None,
) -> list[MetricValue]:
"""
Get metrics scoped to a specific merchant.
Provides:
- Total stores owned by this merchant
- Active stores
- Distinct active team members across all merchant stores
"""
from app.modules.tenancy.models import Store, StoreUser
try:
total_stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.count()
)
active_stores = (
db.query(Store)
.filter(
Store.merchant_id == merchant_id,
Store.is_active == True,
)
.count()
)
# Distinct active team members across all merchant stores
merchant_store_ids = (
db.query(Store.id)
.filter(Store.merchant_id == merchant_id)
.subquery()
)
team_members = (
db.query(func.count(func.distinct(StoreUser.user_id)))
.filter(
StoreUser.store_id.in_(merchant_store_ids),
StoreUser.is_active == True,
)
.scalar()
or 0
)
return [
MetricValue(
key="tenancy.total_stores",
value=total_stores,
label="Total Stores",
category="tenancy",
icon="shopping-bag",
description="Total stores owned by this merchant",
),
MetricValue(
key="tenancy.active_stores",
value=active_stores,
label="Active Stores",
category="tenancy",
icon="check-circle",
description="Active stores owned by this merchant",
),
MetricValue(
key="tenancy.team_members",
value=team_members,
label="Team Members",
category="tenancy",
icon="users",
description="Distinct active team members across all stores",
),
]
except Exception as e:
logger.warning(f"Failed to get tenancy merchant metrics: {e}")
return []
# Singleton instance
tenancy_metrics_provider = TenancyMetricsProvider()

View File

@@ -51,8 +51,7 @@ function adminMerchantDetail() {
this.merchantId = match[1];
merchantDetailLog.info('Viewing merchant:', this.merchantId);
await this.loadMerchant();
await this.loadPlatforms();
await this.loadSubscriptions();
await Promise.all([this.loadPlatforms(), this.loadSubscriptions()]);
} else {
merchantDetailLog.error('No merchant ID in URL');
this.error = 'Invalid merchant URL';
@@ -110,31 +109,19 @@ function adminMerchantDetail() {
}
},
// Load subscriptions for all platforms
// Load all subscriptions for this merchant in a single call
async loadSubscriptions() {
if (!this.merchantId || this.platforms.length === 0) return;
if (!this.merchantId) return;
merchantDetailLog.info('Loading subscriptions for merchant:', this.merchantId);
this.subscriptions = [];
for (const platform of this.platforms) {
try {
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${platform.id}`;
const response = await apiClient.get(url);
const sub = response.subscription || response;
this.subscriptions.push({
subscription: sub,
tier: response.tier || null,
features: response.features || [],
platform_id: platform.id,
platform_name: platform.name,
});
} catch (error) {
if (error.status !== 404) {
merchantDetailLog.warn(`Failed to load subscription for platform ${platform.name}:`, error.message);
}
}
try {
const url = `/admin/subscriptions/merchants/${this.merchantId}`;
const response = await apiClient.get(url);
this.subscriptions = response.subscriptions || [];
} catch (error) {
merchantDetailLog.warn('Failed to load subscriptions:', error.message);
}
merchantDetailLog.info('Subscriptions loaded:', {

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

@@ -151,28 +151,9 @@ function merchantProfile() {
this.loadProfile();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
async loadProfile() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
try {
const resp = await fetch('/api/v1/merchants/account/profile', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
if (!resp.ok) throw new Error('Failed to load profile');
const data = await resp.json();
const data = await apiClient.get('/merchants/account/profile');
this.form.name = data.name || '';
this.form.contact_email = data.contact_email || data.email || '';
@@ -193,22 +174,9 @@ function merchantProfile() {
this.error = null;
this.successMessage = null;
const token = this.getToken();
try {
const resp = await fetch('/api/v1/merchants/account/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(this.form)
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.detail || 'Failed to save profile');
}
await apiClient.put('/merchants/account/profile', this.form);
this.successMessage = 'Profile updated successfully.';
// Auto-hide success message after 3 seconds
setTimeout(() => { this.successMessage = null; }, 3000);
} catch (err) {
this.error = err.message;

View File

@@ -92,28 +92,9 @@ function merchantStores() {
this.loadStores();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
async loadStores() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
try {
const resp = await fetch('/api/v1/merchants/account/stores', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
if (!resp.ok) throw new Error('Failed to load stores');
const data = await resp.json();
const data = await apiClient.get('/merchants/account/stores');
this.stores = data.stores || data.items || [];
} catch (err) {
console.error('Error loading stores:', err);

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

@@ -0,0 +1,132 @@
# app/modules/tenancy/tests/unit/test_tenancy_metrics.py
"""Unit tests for TenancyMetricsProvider.get_merchant_metrics."""
import uuid
import pytest
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
from app.modules.tenancy.services.tenancy_metrics import TenancyMetricsProvider
@pytest.fixture
def metrics_merchant(db):
"""Create a merchant owner and merchant for metrics tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"metricsowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"metricsowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.flush()
merchant = Merchant(
name="Metrics Test Merchant",
owner_user_id=user.id,
contact_email=user.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def metrics_stores(db, metrics_merchant):
"""Create 3 stores (2 active, 1 inactive) for the merchant."""
stores = []
for i, active in enumerate([True, True, False]):
uid = uuid.uuid4().hex[:8].upper()
store = Store(
merchant_id=metrics_merchant.id,
store_code=f"MSTORE_{uid}",
subdomain=f"mstore{uid.lower()}",
name=f"Metrics Store {i}",
is_active=active,
is_verified=True,
)
db.add(store)
stores.append(store)
db.commit()
for s in stores:
db.refresh(s)
return stores
@pytest.fixture
def metrics_team_members(db, metrics_stores):
"""Create team members across merchant stores."""
from middleware.auth import AuthManager
auth = AuthManager()
users = []
for i in range(3):
u = User(
email=f"team_{uuid.uuid4().hex[:8]}@test.com",
username=f"team_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="store_user",
is_active=True,
)
db.add(u)
users.append(u)
db.flush()
# User 0 on store 0 and store 1 (should be counted once)
db.add(StoreUser(store_id=metrics_stores[0].id, user_id=users[0].id, is_active=True))
db.add(StoreUser(store_id=metrics_stores[1].id, user_id=users[0].id, is_active=True))
# User 1 on store 0 only
db.add(StoreUser(store_id=metrics_stores[0].id, user_id=users[1].id, is_active=True))
# User 2 on store 0 but inactive
db.add(StoreUser(store_id=metrics_stores[0].id, user_id=users[2].id, is_active=False))
db.commit()
return users
@pytest.mark.unit
@pytest.mark.tenancy
class TestTenancyMetricsProviderMerchant:
"""Tests for TenancyMetricsProvider.get_merchant_metrics."""
def setup_method(self):
self.provider = TenancyMetricsProvider()
def test_total_stores(self, db, metrics_merchant, metrics_stores):
"""Returns correct total store count for merchant."""
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["tenancy.total_stores"] == 3
def test_active_stores(self, db, metrics_merchant, metrics_stores):
"""Returns correct active store count (excludes inactive)."""
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["tenancy.active_stores"] == 2
def test_team_members_distinct(self, db, metrics_merchant, metrics_stores, metrics_team_members):
"""Counts distinct active team members across stores."""
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
by_key = {m.key: m.value for m in metrics}
# 2 active distinct users (user 0 on 2 stores counted once, user 1, user 2 inactive)
assert by_key["tenancy.team_members"] == 2
def test_no_stores(self, db, metrics_merchant):
"""Returns zero counts when merchant has no stores."""
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
by_key = {m.key: m.value for m in metrics}
assert by_key["tenancy.total_stores"] == 0
assert by_key["tenancy.active_stores"] == 0
assert by_key["tenancy.team_members"] == 0
def test_nonexistent_merchant(self, db):
"""Returns zero counts for a non-existent merchant ID."""
metrics = self.provider.get_merchant_metrics(db, 999999)
by_key = {m.key: m.value for m in metrics}
assert by_key["tenancy.total_stores"] == 0

View File

@@ -1,60 +1,5 @@
{# app/templates/merchant/partials/sidebar.html #}
{# Collapsible sidebar sections with localStorage persistence - matching store 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 static href (no storeCode needed) #}
{% 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="{{ 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 admin pattern #}
{# ============================================================================
SIDEBAR CONTENT (shared between desktop and mobile)
@@ -69,24 +14,92 @@
<span>Merchant Portal</span>
</a>
<!-- Dashboard (always visible) -->
<ul class="mt-6">
{{ menu_item('dashboard', '/merchants/dashboard', 'home', 'Dashboard') }}
</ul>
<!-- 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>
<!-- Billing Section -->
{{ section_header('Billing', 'billing', 'credit-card') }}
{% call section_content('billing') %}
{{ menu_item('subscriptions', '/merchants/billing/subscriptions', 'clipboard-list', 'Subscriptions') }}
{{ menu_item('billing', '/merchants/billing/billing', 'currency-euro', 'Billing History') }}
{% endcall %}
<!-- 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">
<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>
<!-- Account Section -->
{{ section_header('Account', 'account', 'cog') }}
{% call section_content('account') %}
{{ menu_item('stores', '/merchants/account/stores', 'shopping-bag', 'Stores') }}
{{ menu_item('profile', '/merchants/account/profile', 'user', 'Profile') }}
{% 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>
<!-- 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="/merchants/dashboard">
<span x-html="$icon('home', 'w-5 h-5')"></span>
<span class="ml-4">Dashboard</span>
</a>
</li>
</ul>
</div>
</div>
{% endmacro %}

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) -->
<ul class="mt-6">
{{ menu_item('dashboard', 'dashboard', 'home', 'Dashboard') }}
{{ menu_item('analytics', 'analytics', 'chart-bar', 'Analytics') }}
</ul>
<!-- 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>
<!-- 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 %}
<!-- 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">
<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>
<!-- 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 %}
{# 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>
<!-- 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 %}
<!-- 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>
<!-- 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')">