feat: complete dynamic menu system across all frontends
All checks were successful
All checks were successful
- Add "Merchant Frontend" tab to admin menu-config page - Merchant render endpoint now respects AdminMenuConfig visibility via get_merchant_primary_platform_id() platform resolution - New store menu render endpoint (GET /store/core/menu/render/store) with platform-scoped visibility and store_code interpolation - Store sidebar migrated from hardcoded Jinja2 macros to dynamic Alpine.js x-for rendering with loading skeleton and fallback - Store init-alpine.js: add loadMenuConfig(), expandSectionForCurrentPage() - Include store page route fixes, login template updates, and tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -101,10 +101,16 @@ async def get_rendered_merchant_menu(
|
|||||||
# Get union of enabled module codes across all subscribed platforms
|
# Get union of enabled module codes across all subscribed platforms
|
||||||
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
|
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
|
||||||
|
|
||||||
# Get filtered menu using enabled_module_codes override
|
# Resolve primary platform for AdminMenuConfig visibility lookup
|
||||||
|
primary_platform_id = menu_service.get_merchant_primary_platform_id(
|
||||||
|
db, merchant.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get filtered menu using enabled_module_codes override + platform visibility
|
||||||
menu = menu_service.get_menu_for_rendering(
|
menu = menu_service.get_menu_for_rendering(
|
||||||
db=db,
|
db=db,
|
||||||
frontend_type=FrontendType.MERCHANT,
|
frontend_type=FrontendType.MERCHANT,
|
||||||
|
platform_id=primary_platform_id,
|
||||||
enabled_module_codes=enabled_codes,
|
enabled_module_codes=enabled_codes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ Core module store API routes.
|
|||||||
Aggregates:
|
Aggregates:
|
||||||
- /dashboard/* - Dashboard statistics
|
- /dashboard/* - Dashboard statistics
|
||||||
- /settings/* - Store settings management
|
- /settings/* - Store settings management
|
||||||
|
- /menu/* - Store menu rendering
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from .store_dashboard import store_dashboard_router
|
from .store_dashboard import store_dashboard_router
|
||||||
|
from .store_menu import store_menu_router
|
||||||
from .store_settings import store_settings_router
|
from .store_settings import store_settings_router
|
||||||
|
|
||||||
store_router = APIRouter()
|
store_router = APIRouter()
|
||||||
|
|
||||||
# Aggregate sub-routers
|
# Aggregate sub-routers
|
||||||
store_router.include_router(store_dashboard_router, tags=["store-dashboard"])
|
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"])
|
store_router.include_router(store_settings_router, tags=["store-settings"])
|
||||||
|
|||||||
139
app/modules/core/routes/api/store_menu.py
Normal file
139
app/modules/core/routes/api/store_menu.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
Core Store Page Routes (HTML rendering).
|
Core Store Page Routes (HTML rendering).
|
||||||
|
|
||||||
Store pages for core functionality:
|
Store pages for core functionality:
|
||||||
|
- Dashboard
|
||||||
- Media library
|
- Media library
|
||||||
- Notifications
|
- Notifications
|
||||||
"""
|
"""
|
||||||
@@ -19,6 +20,35 @@ from app.templates_config import templates
|
|||||||
router = APIRouter()
|
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
|
# MEDIA LIBRARY
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -325,6 +325,106 @@ class MenuService:
|
|||||||
|
|
||||||
return all_enabled
|
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(
|
def get_merchant_for_menu(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -21,14 +21,20 @@ function getStoreSidebarSectionsFromStorage() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[STORE INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
|
console.warn('[STORE INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
|
||||||
}
|
}
|
||||||
// Default: all sections open
|
// Default: empty (populated dynamically from API response)
|
||||||
return {
|
return {};
|
||||||
products: true,
|
}
|
||||||
sales: true,
|
|
||||||
customers: true,
|
function findSectionForPage(menuData, pageId) {
|
||||||
shop: true,
|
if (!menuData?.sections) return null;
|
||||||
account: true
|
for (const section of menuData.sections) {
|
||||||
};
|
for (const item of (section.items || [])) {
|
||||||
|
if (item.id === pageId) {
|
||||||
|
return section.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveStoreSidebarSectionsToStorage(sections) {
|
function saveStoreSidebarSectionsToStorage(sections) {
|
||||||
@@ -51,6 +57,10 @@ function data() {
|
|||||||
store: null,
|
store: null,
|
||||||
storeCode: null,
|
storeCode: null,
|
||||||
|
|
||||||
|
// Dynamic menu state
|
||||||
|
menuData: null,
|
||||||
|
menuLoading: false,
|
||||||
|
|
||||||
// Sidebar collapsible sections state
|
// Sidebar collapsible sections state
|
||||||
openSections: getStoreSidebarSectionsFromStorage(),
|
openSections: getStoreSidebarSectionsFromStorage(),
|
||||||
|
|
||||||
@@ -81,8 +91,9 @@ function data() {
|
|||||||
this.dark = true;
|
this.dark = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load store info
|
// Load store info and dynamic menu
|
||||||
this.loadStoreInfo();
|
this.loadStoreInfo();
|
||||||
|
this.loadMenuConfig();
|
||||||
|
|
||||||
// Save last visited page (for redirect after login)
|
// Save last visited page (for redirect after login)
|
||||||
// Exclude login, logout, onboarding, error pages
|
// Exclude login, logout, onboarding, error pages
|
||||||
@@ -111,6 +122,38 @@ function data() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadMenuConfig() {
|
||||||
|
if (this.menuData || this.menuLoading) return;
|
||||||
|
if (typeof apiClient === 'undefined') return;
|
||||||
|
if (!localStorage.getItem('store_token')) return;
|
||||||
|
|
||||||
|
this.menuLoading = true;
|
||||||
|
try {
|
||||||
|
this.menuData = await apiClient.get('/store/core/menu/render/store');
|
||||||
|
// Initialize section open state from response
|
||||||
|
for (const section of (this.menuData?.sections || [])) {
|
||||||
|
if (this.openSections[section.id] === undefined) {
|
||||||
|
this.openSections[section.id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveStoreSidebarSectionsToStorage(this.openSections);
|
||||||
|
this.expandSectionForCurrentPage();
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('Menu config not loaded, using fallback:', e?.message || e);
|
||||||
|
} finally {
|
||||||
|
this.menuLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
expandSectionForCurrentPage() {
|
||||||
|
if (!this.menuData) return;
|
||||||
|
const sectionId = findSectionForPage(this.menuData, this.currentPage);
|
||||||
|
if (sectionId && !this.openSections[sectionId]) {
|
||||||
|
this.openSections[sectionId] = true;
|
||||||
|
saveStoreSidebarSectionsToStorage(this.openSections);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
toggleSideMenu() {
|
toggleSideMenu() {
|
||||||
this.isSideMenuOpen = !this.isSideMenuOpen;
|
this.isSideMenuOpen = !this.isSideMenuOpen;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,7 +4,6 @@ Marketplace Store Page Routes (HTML rendering).
|
|||||||
|
|
||||||
Store pages for marketplace management:
|
Store pages for marketplace management:
|
||||||
- Onboarding wizard
|
- Onboarding wizard
|
||||||
- Dashboard
|
|
||||||
- Marketplace imports
|
- Marketplace imports
|
||||||
- Letzshop integration
|
- 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.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.core.utils.page_context import get_store_context
|
||||||
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
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 import User
|
||||||
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -39,14 +40,27 @@ async def store_onboarding_page(
|
|||||||
"""
|
"""
|
||||||
Render store onboarding wizard.
|
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
|
1. Merchant Profile Setup
|
||||||
2. Letzshop API Configuration
|
2. Letzshop API Configuration
|
||||||
3. Product & Order Import Configuration
|
3. Product & Order Import Configuration
|
||||||
4. Order Sync (historical import)
|
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)
|
onboarding_service = OnboardingService(db)
|
||||||
if onboarding_service.is_completed(current_user.token_store_id):
|
if onboarding_service.is_completed(current_user.token_store_id):
|
||||||
return RedirectResponse(
|
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
|
# MARKETPLACE IMPORTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -115,7 +91,15 @@ async def store_marketplace_page(
|
|||||||
"""
|
"""
|
||||||
Render marketplace import page.
|
Render marketplace import page.
|
||||||
JavaScript loads import jobs and products via API.
|
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(
|
return templates.TemplateResponse(
|
||||||
"marketplace/store/marketplace.html",
|
"marketplace/store/marketplace.html",
|
||||||
get_store_context(request, db, current_user, store_code),
|
get_store_context(request, db, current_user, store_code),
|
||||||
@@ -139,7 +123,15 @@ async def store_letzshop_page(
|
|||||||
"""
|
"""
|
||||||
Render Letzshop integration page.
|
Render Letzshop integration page.
|
||||||
JavaScript loads orders, credentials status, and handles fulfillment operations.
|
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(
|
return templates.TemplateResponse(
|
||||||
"marketplace/store/letzshop.html",
|
"marketplace/store/letzshop.html",
|
||||||
get_store_context(request, db, current_user, store_code),
|
get_store_context(request, db, current_user, store_code),
|
||||||
|
|||||||
@@ -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"]
|
||||||
298
app/modules/marketplace/tests/unit/test_store_page_routes.py
Normal file
298
app/modules/marketplace/tests/unit/test_store_page_routes.py
Normal 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"
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
<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>
|
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
|
||||||
Store Frontend
|
Store Frontend
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -181,10 +181,24 @@
|
|||||||
<!-- 4. API Client -->
|
<!-- 4. API Client -->
|
||||||
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||||
|
|
||||||
<!-- 5. Alpine.js v3 -->
|
<!-- 5. Alpine.js v3 with CDN fallback -->
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
<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>
|
<script defer src="{{ url_for('tenancy_static', path='store/js/login.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,60 +1,5 @@
|
|||||||
{# app/templates/store/partials/sidebar.html #}
|
{# app/templates/store/partials/sidebar.html #}
|
||||||
{# Collapsible sidebar sections with localStorage persistence - matching admin pattern #}
|
{# Dynamic sidebar driven by menu discovery API - matching merchant/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 %}
|
|
||||||
|
|
||||||
{# ============================================================================
|
{# ============================================================================
|
||||||
SIDEBAR CONTENT (shared between desktop and mobile)
|
SIDEBAR CONTENT (shared between desktop and mobile)
|
||||||
@@ -69,57 +14,94 @@
|
|||||||
<span x-text="store?.name || 'Store Portal'"></span>
|
<span x-text="store?.name || 'Store Portal'"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Dashboard (always visible) -->
|
<!-- Loading skeleton -->
|
||||||
<ul class="mt-6">
|
<div x-show="menuLoading" class="mt-6 px-6 space-y-4 animate-pulse">
|
||||||
{{ menu_item('dashboard', 'dashboard', 'home', 'Dashboard') }}
|
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
{{ menu_item('analytics', 'analytics', 'chart-bar', 'Analytics') }}
|
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
||||||
</ul>
|
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Products & Inventory Section -->
|
<!-- Dynamic menu from API -->
|
||||||
{{ section_header('Products & Inventory', 'products', 'cube') }}
|
<div x-show="!menuLoading && menuData" x-cloak>
|
||||||
{% call section_content('products') %}
|
<template x-for="section in (menuData?.sections || [])" :key="section.id">
|
||||||
{{ menu_item('products', 'products', 'shopping-bag', 'All Products') }}
|
<div>
|
||||||
{{ menu_item('inventory', 'inventory', 'clipboard-list', 'Inventory') }}
|
{# Unlabeled sections (e.g. "main" with dashboard) render items directly #}
|
||||||
{{ menu_item('marketplace', 'marketplace', 'download', 'Marketplace Import') }}
|
<template x-if="!section.label">
|
||||||
{% endcall %}
|
<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 -->
|
{# Labeled sections are collapsible #}
|
||||||
{{ section_header('Sales & Orders', 'sales', 'shopping-cart') }}
|
<template x-if="section.label">
|
||||||
{% call section_content('sales') %}
|
<div>
|
||||||
{{ menu_item('orders', 'orders', 'document-text', 'Orders') }}
|
<div class="px-6 my-4">
|
||||||
{{ menu_item('letzshop', 'letzshop', 'external-link', 'Letzshop Orders') }}
|
<hr class="border-gray-200 dark:border-gray-700" />
|
||||||
{{ menu_item('invoices', 'invoices', 'currency-euro', 'Invoices') }}
|
</div>
|
||||||
{% endcall %}
|
<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 -->
|
<!-- Fallback: static dashboard link (if menu API fails) -->
|
||||||
{{ section_header('Customers', 'customers', 'users') }}
|
<div x-show="!menuLoading && !menuData" x-cloak>
|
||||||
{% call section_content('customers') %}
|
<ul class="mt-6">
|
||||||
{{ menu_item('customers', 'customers', 'user-group', 'All Customers') }}
|
<li class="relative px-6 py-3">
|
||||||
{{ menu_item('messages', 'messages', 'chat-bubble-left-right', 'Messages') }}
|
<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>
|
||||||
{{ menu_item('notifications', 'notifications', 'bell', 'Notifications') }}
|
<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"
|
||||||
{% endcall %}
|
: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 -->
|
<!-- Quick Actions (static, outside dynamic menu) -->
|
||||||
{{ 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 -->
|
|
||||||
<div class="px-6 my-6">
|
<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"
|
<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')">
|
@click="$dispatch('open-add-product-modal')">
|
||||||
|
|||||||
Reference in New Issue
Block a user