diff --git a/app/api/deps.py b/app/api/deps.py index 72bba9c7..55ebd74e 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -620,9 +620,9 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"): ) if user_context.is_super_admin: - # Super admin: check user-level config - platform_id = None - user_id = user_context.id + # Super admin: use platform from token if selected, else global (no filtering) + platform_id = user_context.token_platform_id + user_id = None else: # Platform admin: need platform context # Try to get from request state diff --git a/app/modules/analytics/definition.py b/app/modules/analytics/definition.py index 823a2f72..c45ddde3 100644 --- a/app/modules/analytics/definition.py +++ b/app/modules/analytics/definition.py @@ -96,6 +96,7 @@ analytics_module = ModuleDefinition( icon="chart-bar", route="/store/{store_code}/analytics", order=20, + requires_permission="analytics.view", ), ], ), diff --git a/app/modules/billing/static/shared/js/upgrade-prompts.js b/app/modules/billing/static/shared/js/upgrade-prompts.js index a81a7d79..3113af3a 100644 --- a/app/modules/billing/static/shared/js/upgrade-prompts.js +++ b/app/modules/billing/static/shared/js/upgrade-prompts.js @@ -82,7 +82,7 @@ this.loading = true; this.error = null; - const response = await apiClient.get('/store/usage'); + const response = await apiClient.get('/store/billing/usage'); this.usage = response; this.loaded = true; diff --git a/app/modules/checkout/routes/api/storefront.py b/app/modules/checkout/routes/api/storefront.py index 1c8fe66e..3fae32d8 100644 --- a/app/modules/checkout/routes/api/storefront.py +++ b/app/modules/checkout/routes/api/storefront.py @@ -11,7 +11,6 @@ Requires customer authentication for order placement. """ import logging -from datetime import UTC, datetime from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session @@ -92,15 +91,21 @@ def place_order( }, ) - # Update customer stats - customer.total_orders = (customer.total_orders or 0) + 1 - customer.total_spent = (customer.total_spent or 0) + order.total_amount - customer.last_order_date = datetime.now(UTC) - db.flush() + # Update customer order stats (owned by orders module) + from app.modules.orders.services.customer_order_service import ( + customer_order_service, + ) + + stats = customer_order_service.record_order( + db=db, + store_id=store.id, + customer_id=customer.id, + total_amount_cents=order.total_amount_cents, + ) logger.debug( - f"Updated customer stats: total_orders={customer.total_orders}, " - f"total_spent={customer.total_spent}" + f"Updated customer order stats: total_orders={stats.total_orders}, " + f"total_spent_cents={stats.total_spent_cents}" ) # Clear cart (get session_id from request cookies or headers) diff --git a/app/modules/contracts/onboarding.py b/app/modules/contracts/onboarding.py index 1113151e..6090d434 100644 --- a/app/modules/contracts/onboarding.py +++ b/app/modules/contracts/onboarding.py @@ -23,8 +23,8 @@ Usage: return [ OnboardingStepDefinition( key="marketplace.connect_api", - title_key="onboarding.marketplace.connect_api.title", - description_key="onboarding.marketplace.connect_api.description", + title_key="marketplace.onboarding.connect_api.title", + description_key="marketplace.onboarding.connect_api.description", icon="plug", route_template="/store/{store_code}/letzshop", order=200, diff --git a/app/modules/core/definition.py b/app/modules/core/definition.py index eebddf3d..2af30a51 100644 --- a/app/modules/core/definition.py +++ b/app/modules/core/definition.py @@ -66,7 +66,6 @@ core_module = ModuleDefinition( "dashboard", "settings", "email-templates", - "my-menu", ], FrontendType.STORE: [ "dashboard", @@ -112,15 +111,6 @@ core_module = ModuleDefinition( order=10, is_mandatory=True, ), - MenuItemDefinition( - id="my-menu", - label_key="core.menu.my_menu", - icon="view-grid", - route="/admin/my-menu", - order=30, - is_mandatory=True, - is_super_admin_only=True, - ), ], ), ], diff --git a/app/modules/core/locales/de.json b/app/modules/core/locales/de.json index f357a56f..37efd140 100644 --- a/app/modules/core/locales/de.json +++ b/app/modules/core/locales/de.json @@ -72,7 +72,6 @@ "dashboard": "Dashboard", "platform_settings": "Plattform-Einstellungen", "general": "Allgemein", - "my_menu": "Mein Menü", "account_settings": "Kontoeinstellungen", "profile": "Profil", "settings": "Einstellungen" diff --git a/app/modules/core/locales/en.json b/app/modules/core/locales/en.json index 44b4521b..4c492645 100644 --- a/app/modules/core/locales/en.json +++ b/app/modules/core/locales/en.json @@ -92,7 +92,6 @@ "dashboard": "Dashboard", "platform_settings": "Platform Settings", "general": "General", - "my_menu": "My Menu", "account_settings": "Account Settings", "profile": "Profile", "settings": "Settings" diff --git a/app/modules/core/locales/fr.json b/app/modules/core/locales/fr.json index 62156d14..98dac978 100644 --- a/app/modules/core/locales/fr.json +++ b/app/modules/core/locales/fr.json @@ -72,7 +72,6 @@ "dashboard": "Tableau de bord", "platform_settings": "Paramètres de la plateforme", "general": "Général", - "my_menu": "Mon menu", "account_settings": "Paramètres du compte", "profile": "Profil", "settings": "Paramètres" diff --git a/app/modules/core/locales/lb.json b/app/modules/core/locales/lb.json index ff56ed40..69d46f0e 100644 --- a/app/modules/core/locales/lb.json +++ b/app/modules/core/locales/lb.json @@ -72,7 +72,6 @@ "dashboard": "Dashboard", "platform_settings": "Plattform-Astellungen", "general": "Allgemeng", - "my_menu": "Mäi Menü", "account_settings": "Kont-Astellungen", "profile": "Profil", "settings": "Astellungen" diff --git a/app/modules/core/models/admin_menu_config.py b/app/modules/core/models/admin_menu_config.py index 0217cd81..023f4867 100644 --- a/app/modules/core/models/admin_menu_config.py +++ b/app/modules/core/models/admin_menu_config.py @@ -100,12 +100,15 @@ class AdminMenuConfig(Base, TimestampMixin): comment="Platform scope - applies to users/stores of this platform", ) + # DEPRECATED: user_id scoping is no longer used. Super admins now use platform + # selection instead of personal menu config. DB migration to drop this column + # is a separate task. user_id = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True, - comment="User scope - applies to this specific super admin (admin frontend only)", + comment="DEPRECATED - User scope no longer used. Kept for migration compatibility.", ) # ======================================================================== diff --git a/app/modules/core/routes/api/admin_menu_config.py b/app/modules/core/routes/api/admin_menu_config.py index 518a3d96..5475b8cc 100644 --- a/app/modules/core/routes/api/admin_menu_config.py +++ b/app/modules/core/routes/api/admin_menu_config.py @@ -6,8 +6,6 @@ Provides menu visibility configuration for admin and store frontends: - GET /menu-config/platforms/{platform_id} - Get menu config for a platform - PUT /menu-config/platforms/{platform_id} - Update menu visibility for a platform - POST /menu-config/platforms/{platform_id}/reset - Reset to defaults -- GET /menu-config/user - Get current user's menu config (super admins) -- PUT /menu-config/user - Update current user's menu config (super admins) - GET /menu/admin - Get rendered admin menu for current user - GET /menu/store - Get rendered store menu for current platform @@ -316,108 +314,6 @@ async def reset_platform_menu_config( return {"success": True, "message": "Menu configuration reset to defaults"} -# ============================================================================= -# User Menu Configuration (Super Admin Only) -# ============================================================================= - - -@router.get("/user", response_model=MenuConfigResponse) -async def get_user_menu_config( - request: Request, - db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_super_admin), -): - """ - Get the current super admin's personal menu configuration. - - Only super admins can configure their own admin menu. - """ - items = menu_service.get_user_menu_config(db, current_user.id) - - logger.info( - f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config" - ) - - # Use user's preferred language, falling back to middleware-resolved language - language = current_user.preferred_language or getattr( - request.state, "language", "en" - ) - - return _build_menu_config_response( - items, FrontendType.ADMIN, language=language, user_id=current_user.id - ) - - -@router.put("/user") -async def update_user_menu_visibility( - update_data: MenuVisibilityUpdateRequest, - db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_super_admin), -): - """ - Update visibility for a single menu item for the current super admin. - - Super admin only. Cannot hide mandatory items. - """ - menu_service.update_menu_visibility( - db=db, - frontend_type=FrontendType.ADMIN, - menu_item_id=update_data.menu_item_id, - is_visible=update_data.is_visible, - user_id=current_user.id, - ) - db.commit() - - logger.info( - f"[MENU_CONFIG] Super admin {current_user.email} updated personal menu: " - f"{update_data.menu_item_id}={update_data.is_visible}" - ) - - return {"success": True, "message": "Menu visibility updated"} - - -@router.post("/user/reset", response_model=MenuActionResponse) -async def reset_user_menu_config( - db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_super_admin), -): - """ - Reset the current super admin's menu configuration (hide all except mandatory). - - Super admin only. - """ - menu_service.reset_user_menu_config(db, current_user.id) - db.commit() - - logger.info( - f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)" - ) - - return MenuActionResponse( - success=True, message="Menu configuration reset - all items hidden" - ) - - -@router.post("/user/show-all", response_model=MenuActionResponse) -async def show_all_user_menu_config( - db: Session = Depends(get_db), - current_user: UserContext = Depends(get_current_super_admin), -): - """ - Show all menu items for the current super admin. - - Super admin only. - """ - menu_service.show_all_user_menu_config(db, current_user.id) - db.commit() - - logger.info( - f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items" - ) - - return MenuActionResponse(success=True, message="All menu items are now visible") - - @router.post("/platforms/{platform_id}/show-all") async def show_all_platform_menu_config( platform_id: int = Path(..., description="Platform ID"), @@ -467,11 +363,12 @@ async def get_rendered_admin_menu( Used by the frontend to render the sidebar. """ if current_user.is_super_admin: - # Super admin: use user-level config + # Super admin: use platform config if platform selected, else global (all modules) + platform_id = current_user.token_platform_id menu = menu_service.get_menu_for_rendering( db=db, frontend_type=FrontendType.ADMIN, - user_id=current_user.id, + platform_id=platform_id, is_super_admin=True, ) else: diff --git a/app/modules/core/routes/api/merchant_menu.py b/app/modules/core/routes/api/merchant_menu.py index b52a9f55..29428412 100644 --- a/app/modules/core/routes/api/merchant_menu.py +++ b/app/modules/core/routes/api/merchant_menu.py @@ -5,9 +5,11 @@ 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. +Menu sections are grouped by platform: +- Core items (dashboard, billing, account) appear at root level +- Platform-specific items are grouped under their platform name +- No AdminMenuConfig visibility filtering — menu is purely driven by + module definitions + module enablement + subscription status """ import logging @@ -38,6 +40,9 @@ class MenuSectionResponse(BaseModel): id: str label: str | None = None + icon: str | None = None + platform_code: str | None = None + is_collapsible: bool = False items: list[dict[str, Any]] @@ -83,10 +88,12 @@ async def get_rendered_merchant_menu( """ 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. + Returns a platform-grouped menu structure: + - Core sections (dashboard, billing, account) at root level + - Platform-specific sections grouped under platform name - Used by the merchant frontend to render the sidebar dynamically. + Menu visibility is driven by module definitions + module enablement + + subscription status. No AdminMenuConfig filtering. """ # Resolve the merchant for this user (via service layer) merchant = menu_service.get_merchant_for_menu(db, current_user.id) @@ -98,32 +105,25 @@ async def get_rendered_merchant_menu( 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( + # Get platform-grouped menu + core_sections, platform_sections = menu_service.get_merchant_menu_by_platform( 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: + # Build response sections + all_sections = [] + + # Core sections first (translated labels) + for section in core_sections: translated_items = [] for item in section.items: + if not item.is_module_enabled: + continue translated_items.append( { "id": item.id, @@ -133,15 +133,59 @@ async def get_rendered_merchant_menu( } ) - sections.append( - MenuSectionResponse( - id=section.id, - label=_translate_label(section.label_key, language), - items=translated_items, + if translated_items: + all_sections.append( + MenuSectionResponse( + id=section.id, + label=_translate_label(section.label_key, language), + icon=section.icon, + is_collapsible=section.is_collapsible, + items=translated_items, + ) ) - ) + + # Platform sections (platform name as label, collapsible) + for section in platform_sections: + translated_items = [] + for item in section.items: + if not item.is_module_enabled: + continue + translated_items.append( + { + "id": item.id, + "label": _translate_label(item.label_key, language), + "icon": item.icon, + "url": item.route, + } + ) + + if translated_items: + # Extract platform_code from section id (format: "platform-{code}") + platform_code = section.id.removeprefix("platform-") + all_sections.append( + MenuSectionResponse( + id=section.id, + label=section.label_key, # Already platform name, not a translation key + icon=section.icon, + platform_code=platform_code, + is_collapsible=True, + items=translated_items, + ) + ) + + # Sort order: 1) Dashboard (main), 2) Platform sections, 3) Billing, 4) Account + # Core sections use their definition order; platform sections slot in between + # main=0, platform=25, billing=50, account=900 + def _section_sort_key(s): + if s.platform_code: + return 25, s.id + # Map known core section IDs to their display order + core_order = {"main": 0, "billing": 50, "account": 900} + return core_order.get(s.id, 500), s.id + + all_sections.sort(key=_section_sort_key) return RenderedMenuResponse( frontend_type=FrontendType.MERCHANT.value, - sections=sections, + sections=all_sections, ) diff --git a/app/modules/core/routes/api/store_settings.py b/app/modules/core/routes/api/store_settings.py index bbb02b9f..5b96fa26 100644 --- a/app/modules/core/routes/api/store_settings.py +++ b/app/modules/core/routes/api/store_settings.py @@ -224,14 +224,6 @@ def get_store_settings( "is_verified": domain.is_verified, }) - # Get Stripe info from subscription (read-only, masked) - stripe_info = None - if store.subscription and store.subscription.stripe_customer_id: - stripe_info = { - "has_stripe_customer": True, - "customer_id_masked": f"cus_***{store.subscription.stripe_customer_id[-4:]}", - } - return { # General info "store_code": store.store_code, @@ -297,9 +289,6 @@ def get_store_settings( "domains": domains, "default_subdomain": f"{store.subdomain}.letzshop.lu", - # Stripe info (read-only) - "stripe_info": stripe_info, - # Options for dropdowns "options": { "supported_languages": SUPPORTED_LANGUAGES, diff --git a/app/modules/core/routes/pages/admin.py b/app/modules/core/routes/pages/admin.py index dd8aff34..7464aea2 100644 --- a/app/modules/core/routes/pages/admin.py +++ b/app/modules/core/routes/pages/admin.py @@ -18,6 +18,7 @@ from app.modules.core.utils.page_context import get_admin_context from app.modules.enums import FrontendType from app.modules.tenancy.models import User from app.templates_config import templates +from app.utils.i18n import get_jinja2_globals router = APIRouter() @@ -57,10 +58,13 @@ async def admin_login_page( if current_user: return RedirectResponse(url="/admin/dashboard", status_code=302) - return templates.TemplateResponse("tenancy/admin/login.html", { + language = getattr(request.state, "language", "en") + context = { "request": request, - "current_language": getattr(request.state, "language", "en"), - }) + "current_language": language, + **get_jinja2_globals(language), + } + return templates.TemplateResponse("tenancy/admin/login.html", context) @router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False) @@ -69,18 +73,15 @@ async def admin_select_platform_page( current_user: User | None = Depends(get_current_admin_optional), ): """ - Render platform selection page for platform admins. + Render platform selection page for admins. Platform admins with access to multiple platforms must select which platform they want to manage before accessing the dashboard. - Super admins are redirected to dashboard (they have global access). + Super admins can optionally select a platform to scope their view. """ if not current_user: return RedirectResponse(url="/admin/login", status_code=302) - if current_user.is_super_admin: - return RedirectResponse(url="/admin/dashboard", status_code=302) - return templates.TemplateResponse( "tenancy/admin/select-platform.html", {"request": request, "user": current_user}, @@ -124,26 +125,6 @@ async def admin_settings_page( ) -@router.get("/my-menu", response_class=HTMLResponse, include_in_schema=False) -async def admin_my_menu_config( - request: Request, - current_user: User = Depends(require_menu_access("my-menu", FrontendType.ADMIN)), - db: Session = Depends(get_db), -): - """ - Render personal menu configuration page for super admins. - Allows super admins to customize their own sidebar menu. - """ - # Only super admins can configure their own menu - if not current_user.is_super_admin: - return RedirectResponse(url="/admin/settings", status_code=302) - - return templates.TemplateResponse( - "core/admin/my-menu-config.html", - get_admin_context(request, db, current_user), - ) - - @router.get("/features", response_class=HTMLResponse, include_in_schema=False) async def admin_features_page( request: Request, diff --git a/app/modules/core/routes/pages/merchant.py b/app/modules/core/routes/pages/merchant.py index 534b58b6..320de998 100644 --- a/app/modules/core/routes/pages/merchant.py +++ b/app/modules/core/routes/pages/merchant.py @@ -24,6 +24,7 @@ from app.modules.core.utils.page_context import get_context_for_frontend from app.modules.enums import FrontendType from app.modules.tenancy.schemas.auth import UserContext from app.templates_config import templates +from app.utils.i18n import get_jinja2_globals ROUTE_CONFIG = { "prefix": "", @@ -67,10 +68,13 @@ async def merchant_login_page( if current_user: return RedirectResponse(url="/merchants/dashboard", status_code=302) - return templates.TemplateResponse("tenancy/merchant/login.html", { + language = getattr(request.state, "language", "fr") + context = { "request": request, - "current_language": getattr(request.state, "language", "fr"), - }) + "current_language": language, + **get_jinja2_globals(language), + } + return templates.TemplateResponse("tenancy/merchant/login.html", context) # ============================================================================ diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index 64d8992c..a791b3d6 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -274,6 +274,103 @@ class MenuService: # Merchant Menu # ========================================================================= + def get_merchant_menu_by_platform( + self, + db: Session, + merchant_id: int, + ) -> tuple[list, list]: + """ + Get merchant menu items grouped by platform. + + Returns two lists of DiscoveredMenuSection: + - core_sections: items from core modules (always visible, no platform grouping) + - platform_sections: items from non-core modules, with platform metadata + + Each platform section has its label_key replaced with the platform name + and a `platform_code` attribute added for frontend identification. + + Args: + db: Database session + merchant_id: Merchant ID + + Returns: + Tuple of (core_sections, platform_sections) + """ + from app.modules.billing.services.subscription_service import ( + subscription_service, + ) + from app.modules.registry import MODULES + from app.modules.tenancy.services.platform_service import platform_service + + core_codes = {code for code, mod in MODULES.items() if mod.is_core} + + # Get all sections from core modules (no platform filtering needed) + core_sections = menu_discovery_service.get_menu_sections_for_frontend( + db, + FrontendType.MERCHANT, + enabled_module_codes=core_codes, + ) + # Filter to only items from core modules + for section in core_sections: + section.items = [i for i in section.items if i.module_code in core_codes] + core_sections = [s for s in core_sections if s.items] + + # Get platform-specific sections + platform_sections = [] + platform_ids = subscription_service.get_active_subscription_platform_ids( + db, merchant_id + ) + + for pid in platform_ids: + platform = platform_service.get_platform_by_id(db, pid) + if not platform: + continue + + # Get modules enabled on this platform (excluding core — already at root) + platform_module_codes = module_service.get_enabled_module_codes(db, pid) + non_core_codes = platform_module_codes - core_codes + + if not non_core_codes: + continue # No platform-specific items + + # Get sections for this platform's non-core modules + sections = menu_discovery_service.get_menu_sections_for_frontend( + db, + FrontendType.MERCHANT, + enabled_module_codes=non_core_codes, + ) + # Filter to only items from non-core modules + for section in sections: + section.items = [ + i for i in section.items if i.module_code in non_core_codes + ] + + # Flatten all platform sections into one section per platform + all_items = [] + for section in sections: + all_items.extend(section.items) + + if not all_items: + continue + + # Create a single section for this platform + from app.modules.core.services.menu_discovery_service import ( + DiscoveredMenuSection, + ) + + platform_section = DiscoveredMenuSection( + id=f"platform-{platform.code}", + label_key=platform.name, # Use platform name directly + icon=getattr(platform, "icon", None) or "globe-alt", + order=30 + platform_ids.index(pid), + is_super_admin_only=False, + is_collapsible=True, + items=sorted(all_items, key=lambda i: (i.section_order, i.order)), + ) + platform_sections.append(platform_section) + + return core_sections, platform_sections + def get_merchant_enabled_module_codes( self, db: Session, @@ -472,60 +569,6 @@ class MenuService: return result - def get_user_menu_config( - self, - db: Session, - user_id: int, - ) -> list[MenuItemConfig]: - """ - Get admin menu configuration for a super admin user. - - Super admins don't have platform context, so all modules are shown. - Module enablement is always True for super admin menu config. - - Args: - db: Database session - user_id: Super admin user ID - - Returns: - List of MenuItemConfig with current visibility state - """ - shown_items = self._get_shown_items(db, FrontendType.ADMIN, user_id=user_id) - mandatory_items = menu_discovery_service.get_mandatory_item_ids( - FrontendType.ADMIN - ) - - # Get all menu items from discovery service - all_items = menu_discovery_service.get_all_menu_items(FrontendType.ADMIN) - - result = [] - for item in all_items: - # If no config exists (shown_items is None), show all by default - # Otherwise, item is visible if in shown_items or mandatory - is_visible = ( - shown_items is None - or item.id in shown_items - or item.id in mandatory_items - ) - - result.append( - MenuItemConfig( - id=item.id, - label=item.label_key, - icon=item.icon, - url=item.route, - section_id=item.section_id, - section_label=item.section_label_key, - is_visible=is_visible, - is_mandatory=item.id in mandatory_items, - is_super_admin_only=item.is_super_admin_only, - is_module_enabled=True, # Super admins see all modules - module_code=item.module_code, - ) - ) - - return result - def update_menu_visibility( self, db: Session, @@ -692,54 +735,6 @@ class MenuService: f"Created {len(all_items) - len(mandatory_items)} hidden records for platform {platform_id}" ) - def reset_user_menu_config( - self, - db: Session, - user_id: int, - ) -> None: - """ - Reset menu configuration for a super admin user to defaults (all hidden except mandatory). - - In opt-in model, reset means hide everything so user can opt-in to what they want. - - Args: - db: Database session - user_id: Super admin user ID - """ - # Delete all existing records - deleted = ( - db.query(AdminMenuConfig) - .filter( - AdminMenuConfig.frontend_type == FrontendType.ADMIN, - AdminMenuConfig.user_id == user_id, - ) - .delete() - ) - logger.info(f"Reset menu config for user {user_id}: deleted {deleted} rows") - - # Create records with is_visible=False for all non-mandatory items - all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN) - mandatory_items = menu_discovery_service.get_mandatory_item_ids( - FrontendType.ADMIN - ) - - configs = [ - AdminMenuConfig( - frontend_type=FrontendType.ADMIN, - platform_id=None, - user_id=user_id, - menu_item_id=item_id, - is_visible=False, - ) - for item_id in all_items - if item_id not in mandatory_items - ] - db.add_all(configs) - - logger.info( - f"Created {len(all_items) - len(mandatory_items)} hidden records for user {user_id}" - ) - def show_all_platform_menu_config( self, db: Session, @@ -789,52 +784,6 @@ class MenuService: f"Created {len(all_items) - len(mandatory_items)} visible records for platform {platform_id}" ) - def show_all_user_menu_config( - self, - db: Session, - user_id: int, - ) -> None: - """ - Show all menu items for a super admin user. - - Args: - db: Database session - user_id: Super admin user ID - """ - # Delete all existing records - deleted = ( - db.query(AdminMenuConfig) - .filter( - AdminMenuConfig.frontend_type == FrontendType.ADMIN, - AdminMenuConfig.user_id == user_id, - ) - .delete() - ) - logger.info(f"Show all menu config for user {user_id}: deleted {deleted} rows") - - # Create records with is_visible=True for all non-mandatory items - all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN) - mandatory_items = menu_discovery_service.get_mandatory_item_ids( - FrontendType.ADMIN - ) - - configs = [ - AdminMenuConfig( - frontend_type=FrontendType.ADMIN, - platform_id=None, - user_id=user_id, - menu_item_id=item_id, - is_visible=True, - ) - for item_id in all_items - if item_id not in mandatory_items - ] - db.add_all(configs) - - logger.info( - f"Created {len(all_items) - len(mandatory_items)} visible records for user {user_id}" - ) - def initialize_menu_config( self, db: Session, diff --git a/app/modules/core/services/onboarding_aggregator.py b/app/modules/core/services/onboarding_aggregator.py index be097db3..b8212eaa 100644 --- a/app/modules/core/services/onboarding_aggregator.py +++ b/app/modules/core/services/onboarding_aggregator.py @@ -39,11 +39,28 @@ class OnboardingAggregatorService: a unified interface for the dashboard onboarding banner. """ + def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]: + """Get platform IDs the store is actively subscribed to.""" + from app.modules.tenancy.models.store_platform import StorePlatform + + rows = ( + db.query(StorePlatform.platform_id) + .filter( + StorePlatform.store_id == store_id, + StorePlatform.is_active.is_(True), + ) + .all() + ) + return {r.platform_id for r in rows} + def _get_enabled_providers( - self, db: Session, platform_id: int + self, db: Session, store_id: int, platform_id: int ) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]: """ - Get onboarding providers from enabled modules. + Get onboarding providers from modules enabled on the store's subscribed platforms. + + Filters non-core modules to only those enabled on platforms the store + is actively subscribed to, preventing cross-platform content leakage. Returns: List of (module, provider) tuples for enabled modules with providers @@ -51,6 +68,11 @@ class OnboardingAggregatorService: from app.modules.registry import MODULES from app.modules.service import module_service + store_platform_ids = self._get_store_platform_ids(db, store_id) + if not store_platform_ids: + # Fallback to the passed platform_id if no subscriptions found + store_platform_ids = {platform_id} + providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = [] for module in MODULES.values(): @@ -59,13 +81,19 @@ class OnboardingAggregatorService: # Core modules are always enabled, check others if not module.is_core: - try: - if not module_service.is_module_enabled(db, platform_id, module.code): - continue - except Exception as e: - logger.warning( - f"Failed to check if module {module.code} is enabled: {e}" - ) + # Check if module is enabled on ANY of the store's subscribed platforms + enabled_on_any = False + for pid in store_platform_ids: + try: + if module_service.is_module_enabled(db, pid, module.code): + enabled_on_any = True + break + except Exception as e: + logger.warning( + f"Failed to check if module {module.code} is enabled " + f"on platform {pid}: {e}" + ) + if not enabled_on_any: continue try: @@ -88,7 +116,7 @@ class OnboardingAggregatorService: Returns: Sorted list of OnboardingStepStatus objects """ - providers = self._get_enabled_providers(db, platform_id) + providers = self._get_enabled_providers(db, store_id, platform_id) steps: list[OnboardingStepStatus] = [] for module, provider in providers: diff --git a/app/modules/core/services/stats_aggregator.py b/app/modules/core/services/stats_aggregator.py index f8f636d2..30f80601 100644 --- a/app/modules/core/services/stats_aggregator.py +++ b/app/modules/core/services/stats_aggregator.py @@ -52,15 +52,33 @@ class StatsAggregatorService: when modules are disabled or providers fail. """ + def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]: + """Get platform IDs the store is actively subscribed to.""" + from app.modules.tenancy.models.store_platform import StorePlatform + + rows = ( + db.query(StorePlatform.platform_id) + .filter( + StorePlatform.store_id == store_id, + StorePlatform.is_active.is_(True), + ) + .all() + ) + return {r.platform_id for r in rows} + def _get_enabled_providers( - self, db: Session, platform_id: int + self, db: Session, platform_id: int, store_id: int | None = None ) -> list[tuple["ModuleDefinition", MetricsProviderProtocol]]: """ Get metrics providers from enabled modules. + When store_id is provided, filters to modules enabled on the store's + subscribed platforms only (prevents cross-platform content leakage). + Args: db: Database session platform_id: Platform ID to check module enablement + store_id: Optional store ID for subscription-aware filtering Returns: List of (module, provider) tuples for enabled modules with providers @@ -68,6 +86,14 @@ class StatsAggregatorService: from app.modules.registry import MODULES from app.modules.service import module_service + # Determine which platform IDs to check + if store_id: + check_platform_ids = self._get_store_platform_ids(db, store_id) + if not check_platform_ids: + check_platform_ids = {platform_id} + else: + check_platform_ids = {platform_id} + providers: list[tuple[ModuleDefinition, MetricsProviderProtocol]] = [] for module in MODULES.values(): @@ -77,13 +103,18 @@ class StatsAggregatorService: # Core modules are always enabled, check others if not module.is_core: - try: - if not module_service.is_module_enabled(db, platform_id, module.code): - continue - except Exception as e: - logger.warning( - f"Failed to check if module {module.code} is enabled: {e}" - ) + enabled_on_any = False + for pid in check_platform_ids: + try: + if module_service.is_module_enabled(db, pid, module.code): + enabled_on_any = True + break + except Exception as e: + logger.warning( + f"Failed to check if module {module.code} is enabled " + f"on platform {pid}: {e}" + ) + if not enabled_on_any: continue # Get the provider instance @@ -119,7 +150,7 @@ class StatsAggregatorService: Returns: Dict mapping category name to list of MetricValue objects """ - providers = self._get_enabled_providers(db, platform_id) + providers = self._get_enabled_providers(db, platform_id, store_id=store_id) result: dict[str, list[MetricValue]] = {} for module, provider in providers: diff --git a/app/modules/core/static/admin/js/init-alpine.js b/app/modules/core/static/admin/js/init-alpine.js index 7702c3aa..b21f4d40 100644 --- a/app/modules/core/static/admin/js/init-alpine.js +++ b/app/modules/core/static/admin/js/init-alpine.js @@ -264,7 +264,7 @@ function languageSelector(currentLang, enabledLanguages) { return { isLangOpen: false, currentLang: currentLang || 'fr', - languages: enabledLanguages || ['fr', 'de', 'en'], + languages: enabledLanguages || ['en', 'fr', 'de', 'lb'], languageNames: { 'en': 'English', 'fr': 'Français', diff --git a/app/modules/core/static/admin/js/login.js b/app/modules/core/static/admin/js/login.js index f46edda7..5b01a8fa 100644 --- a/app/modules/core/static/admin/js/login.js +++ b/app/modules/core/static/admin/js/login.js @@ -6,6 +6,27 @@ // Create custom logger for login page const loginLog = window.LogConfig.createLogger('LOGIN'); +function languageSelector(currentLang, enabledLanguages) { + return { + currentLang: currentLang || 'en', + languages: enabledLanguages || ['en', 'fr', 'de', 'lb'], + async setLanguage(lang) { + if (lang === this.currentLang) return; + try { + // noqa: JS-008 - Login page has no apiClient; raw fetch is intentional + await fetch('/api/v1/platform/language/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language: lang }), + }); + window.location.reload(); + } catch (error) { + loginLog.error('Failed to set language:', error); + } + }, + }; +} + function adminLogin() { return { dark: false, @@ -17,6 +38,10 @@ function adminLogin() { error: null, success: null, errors: {}, + rememberMe: false, + showForgotPassword: false, + forgotPasswordEmail: '', + forgotPasswordLoading: false, init() { // Guard against multiple initialization @@ -196,6 +221,14 @@ function adminLogin() { window.location.href = '/admin/select-platform'; return; } + + if (platformsResponse.is_super_admin && !platformsResponse.current_platform_id) { + // Super admin with no platform selected - offer platform selection + loginLog.info('Super admin without platform, redirecting to platform selector...'); + this.success = 'Login successful! Select a platform or stay in global mode...'; + window.location.href = '/admin/select-platform'; + return; + } } catch (platformError) { loginLog.warn('Could not check platforms, proceeding to dashboard:', platformError); } @@ -233,6 +266,28 @@ function adminLogin() { } }, + async handleForgotPassword() { + if (!this.forgotPasswordEmail) { + this.error = 'Please enter your email address'; + return; + } + this.forgotPasswordLoading = true; + this.clearErrors(); + + try { + const response = await apiClient.post('/admin/auth/forgot-password', { + email: this.forgotPasswordEmail + }); + this.success = response.message || 'If an account exists with that email, a reset link has been sent.'; + this.forgotPasswordEmail = ''; + } catch (error) { + // Show generic message to prevent email enumeration + this.success = 'If an account exists with that email, a reset link has been sent.'; + } finally { + this.forgotPasswordLoading = false; + } + }, + toggleDarkMode() { loginLog.debug('Toggling dark mode...'); this.dark = !this.dark; diff --git a/app/modules/core/static/admin/js/my-menu-config.js b/app/modules/core/static/admin/js/my-menu-config.js deleted file mode 100644 index 7d1e5a76..00000000 --- a/app/modules/core/static/admin/js/my-menu-config.js +++ /dev/null @@ -1,177 +0,0 @@ -// static/admin/js/my-menu-config.js -// Personal menu configuration for super admins -// -// NOTE: The page method for loading user menu config is named loadUserMenuConfig() -// (not loadMenuConfig()) to avoid shadowing the sidebar's loadMenuConfig() inherited -// from data() via the spread operator. Shadowing caused the sidebar to never populate -// its menuData, resulting in a blank sidebar on this page. - -const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console; - -function adminMyMenuConfig() { - return { - // Inherit base layout functionality from init-alpine.js - ...data(), - - // Page-specific state - currentPage: 'my-menu', - loading: true, - error: null, - successMessage: null, - saving: false, - - // Data - menuConfig: null, - showShowAllModal: false, - showHideAllModal: false, - - // Computed grouped items - get groupedItems() { - if (!this.menuConfig?.items) return []; - - // Group items by section - const sections = {}; - for (const item of this.menuConfig.items) { - const sectionId = item.section_id; - if (!sections[sectionId]) { - sections[sectionId] = { - id: sectionId, - label: item.section_label, - isSuperAdminOnly: item.is_super_admin_only, - items: [], - visibleCount: 0 - }; - } - sections[sectionId].items.push(item); - if (item.is_visible) { - sections[sectionId].visibleCount++; - } - } - - // Convert to array and maintain order - return Object.values(sections); - }, - - async init() { - // Guard against multiple initialization - if (window._adminMyMenuConfigInitialized) { - myMenuConfigLog.warn('Already initialized, skipping'); - return; - } - window._adminMyMenuConfigInitialized = true; - - myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ==='); - - try { - // Load core translations for confirmations - await I18n.loadModule('core'); - await this.loadUserMenuConfig(); - myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ==='); - } catch (error) { - myMenuConfigLog.error('Failed to initialize my menu config page:', error); - this.error = 'Failed to load page data. Please refresh.'; - } - }, - - async refresh() { - this.error = null; - this.successMessage = null; - await this.loadUserMenuConfig(); - }, - - async loadUserMenuConfig() { - this.loading = true; - this.error = null; - - try { - this.menuConfig = await apiClient.get('/admin/menu-config/user'); - myMenuConfigLog.info('Loaded menu config:', { - totalItems: this.menuConfig?.total_items, - visibleItems: this.menuConfig?.visible_items - }); - } catch (error) { - myMenuConfigLog.error('Failed to load menu config:', error); - this.error = error.message || 'Failed to load menu configuration'; - } finally { - this.loading = false; - } - }, - - async toggleVisibility(item) { - if (item.is_mandatory) { - myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id); - return; - } - - this.saving = true; - this.error = null; - this.successMessage = null; - - const newVisibility = !item.is_visible; - - try { - await apiClient.put('/admin/menu-config/user', { - menu_item_id: item.id, - is_visible: newVisibility - }); - - // Update local state - item.is_visible = newVisibility; - - // Update counts - if (newVisibility) { - this.menuConfig.visible_items++; - this.menuConfig.hidden_items--; - } else { - this.menuConfig.visible_items--; - this.menuConfig.hidden_items++; - } - - myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility); - - // Reload the page to refresh sidebar - window.location.reload(); - } catch (error) { - myMenuConfigLog.error('Failed to toggle visibility:', error); - this.error = error.message || 'Failed to update menu visibility'; - this.saving = false; - } - }, - - async showAll() { - this.saving = true; - this.error = null; - this.successMessage = null; - - try { - await apiClient.post('/admin/menu-config/user/show-all'); - myMenuConfigLog.info('Showed all menu items'); - - // Reload the page to refresh sidebar - window.location.reload(); - } catch (error) { - myMenuConfigLog.error('Failed to show all menu items:', error); - this.error = error.message || 'Failed to show all menu items'; - this.saving = false; - } - }, - - async resetToDefaults() { - this.saving = true; - this.error = null; - this.successMessage = null; - - try { - await apiClient.post('/admin/menu-config/user/reset'); - myMenuConfigLog.info('Reset menu config to defaults'); - - // Reload the page to refresh sidebar - window.location.reload(); - } catch (error) { - myMenuConfigLog.error('Failed to reset menu config:', error); - this.error = error.message || 'Failed to reset menu configuration'; - this.saving = false; - } - } - }; -} diff --git a/app/modules/core/static/merchant/js/login.js b/app/modules/core/static/merchant/js/login.js index 4f0506c6..2b640122 100644 --- a/app/modules/core/static/merchant/js/login.js +++ b/app/modules/core/static/merchant/js/login.js @@ -5,6 +5,27 @@ // Use centralized logger const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN'); +function languageSelector(currentLang, enabledLanguages) { + return { + currentLang: currentLang || 'fr', + languages: enabledLanguages || ['en', 'fr', 'de', 'lb'], + async setLanguage(lang) { + if (lang === this.currentLang) return; + try { + // noqa: JS-008 - Login page has no apiClient; raw fetch is intentional + await fetch('/api/v1/platform/language/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language: lang }), + }); + window.location.reload(); + } catch (error) { + loginLog.error('Failed to set language:', error); + } + }, + }; +} + function merchantLogin() { return { dark: false, @@ -134,6 +155,7 @@ function merchantLogin() { }, // Forgot password state + rememberMe: false, showForgotPassword: false, forgotPasswordEmail: '', forgotPasswordLoading: false, diff --git a/app/modules/core/static/store/js/dashboard.js b/app/modules/core/static/store/js/dashboard.js index 51128e5c..79bc5997 100644 --- a/app/modules/core/static/store/js/dashboard.js +++ b/app/modules/core/static/store/js/dashboard.js @@ -15,7 +15,9 @@ storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data); * Fetches onboarding steps from API, supports session-scoped dismiss. */ function onboardingBanner() { + const t = (key, vars) => I18n.t(key, vars); return { + t, visible: false, steps: [], totalSteps: 0, @@ -30,7 +32,19 @@ function onboardingBanner() { try { const response = await apiClient.get('/store/dashboard/onboarding'); - this.steps = response.steps || []; + const steps = response.steps || []; + + // Load module translations BEFORE setting reactive data + // Keys are like "tenancy.onboarding...." — first segment is the module + const modules = new Set(); + for (const step of steps) { + const mod = step.title_key?.split('.')[0]; + if (mod) modules.add(mod); + } + await Promise.all([...modules].map(m => I18n.loadModule(m))); + + // Now set reactive data — Alpine re-renders with translations ready + this.steps = steps; this.totalSteps = response.total_steps || 0; this.completedSteps = response.completed_steps || 0; this.progressPercentage = response.progress_percentage || 0; diff --git a/app/modules/core/static/storefront/js/storefront-layout.js b/app/modules/core/static/storefront/js/storefront-layout.js index 9565bd48..30632b16 100644 --- a/app/modules/core/static/storefront/js/storefront-layout.js +++ b/app/modules/core/static/storefront/js/storefront-layout.js @@ -253,7 +253,7 @@ function languageSelector(currentLang, enabledLanguages) { return { isLangOpen: false, currentLang: currentLang || 'fr', - languages: enabledLanguages || ['fr', 'de', 'en'], + languages: enabledLanguages || ['en', 'fr', 'de', 'lb'], languageNames: { 'en': 'English', 'fr': 'Français', diff --git a/app/modules/core/templates/core/admin/my-menu-config.html b/app/modules/core/templates/core/admin/my-menu-config.html deleted file mode 100644 index 0181027a..00000000 --- a/app/modules/core/templates/core/admin/my-menu-config.html +++ /dev/null @@ -1,200 +0,0 @@ -{# app/templates/admin/my-menu-config.html #} -{% extends "admin/base.html" %} -{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %} -{% from 'shared/macros/headers.html' import page_header %} -{% from 'shared/macros/modals.html' import confirm_modal %} - -{% block title %}My Menu{% endblock %} - -{% block alpine_data %}adminMyMenuConfig(){% endblock %} - -{% block content %} -{{ page_header('My Menu Configuration', subtitle='Customize your personal admin sidebar', back_url='/admin/settings') }} - -{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }} -{{ error_state('Error', show_condition='error') }} - - -
-
- -
-

- This configures your personal admin sidebar menu. These settings only affect your view. -

-

- To configure menus for platform admins or stores, go to Platforms and select a platform's Menu Configuration. -

-
-
-
- - -
-
-
- -
-
-

Total Items

-

-
-
- -
-
- -
-
-

Visible

-

-
-
- -
-
- -
-
-

Hidden

-

-
-
-
- - -
-

- Toggle visibility for menu items. Mandatory items cannot be hidden. -

-
- - -
-
- - -
- - Loading menu configuration... -
- - -
- - - -
- -

No menu items available.

-
-
- - -{{ confirm_modal( - 'showAllModal', - 'Show All Menu Items', - 'This will make all menu items visible in your sidebar. Are you sure?', - 'showAll()', - 'showShowAllModal', - 'Show All', - 'Cancel', - 'info', - 'eye' -) }} - -{{ confirm_modal( - 'hideAllModal', - 'Hide All Menu Items', - 'This will hide all non-mandatory menu items from your sidebar. Are you sure?', - 'resetToDefaults()', - 'showHideAllModal', - 'Hide All', - 'Cancel', - 'warning', - 'eye-off' -) }} - -{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/modules/core/templates/core/store/dashboard.html b/app/modules/core/templates/core/store/dashboard.html index 75be0a7e..23e339e5 100644 --- a/app/modules/core/templates/core/store/dashboard.html +++ b/app/modules/core/templates/core/store/dashboard.html @@ -8,7 +8,7 @@ {% block alpine_data %}storeDashboard(){% endblock %} -{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %} +{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner with context %} {% block content %} diff --git a/app/modules/core/templates/core/store/settings.html b/app/modules/core/templates/core/store/settings.html deleted file mode 100644 index 5aabce74..00000000 --- a/app/modules/core/templates/core/store/settings.html +++ /dev/null @@ -1,1406 +0,0 @@ -{# app/templates/store/settings.html #} -{% extends "store/base.html" %} -{% from 'shared/macros/headers.html' import page_header_flex %} -{% from 'shared/macros/alerts.html' import loading_state, error_state %} -{% from 'shared/macros/tabs.html' import tabs_nav, tab_button %} - -{% block title %}Settings{% endblock %} - -{% block alpine_data %}storeSettings(){% endblock %} - -{% block content %} - -{% call page_header_flex(title='Settings', subtitle='Configure your store preferences') %} -{% endcall %} - -{{ loading_state('Loading settings...') }} - -{{ error_state('Error loading settings') }} - - -
- - {% call tabs_nav(tab_var='activeSection') %} - {{ tab_button('general', 'General', tab_var='activeSection', icon='cog') }} - {{ tab_button('business', 'Business', tab_var='activeSection', icon='office-building') }} - {{ tab_button('localization', 'Localization', tab_var='activeSection', icon='globe') }} - {{ tab_button('marketplace', 'Marketplace', tab_var='activeSection', icon='shopping-cart') }} - {{ tab_button('invoices', 'Invoices', tab_var='activeSection', icon='document-text') }} - {{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }} - {{ tab_button('email', 'Email', tab_var='activeSection', icon='mail') }} - {{ tab_button('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }} - {{ tab_button('api', 'API', tab_var='activeSection', icon='key') }} - {{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }} - {% endcall %} - - -
- -
-
-

General Settings

-

Basic store configuration

-
-
-
- -
- -
- - - .letzshop.lu - -
-

Contact support to change your subdomain

-
- - -
-
-

Store Status

-

Your store is currently visible to customers

-
- -
- - -
-
-

Verification Status

-

Verified stores get a badge on their store

-
- -
-
-
-
- - -
-
-

Business Information

-

- Store details and contact information - -

-
-
-
- -
- - -
- - -
- - -
- - -
- -
- - -
-

- Leave empty to use merchant default -

-
- - -
- -
- - -
-
- - -
- -
- - -
-
- - -
- -
- - -
-
- - -
- -
- - -
-
- - -
- -
-
-
-
- - -
-
-

Localization

-

Configure language and regional settings

-
-
-
- -
- -
- - - Platform-wide currency (contact admin to change) - -
-
- - -
- - -

- Controls how prices and numbers are displayed (e.g., "29,99 EUR" vs "EUR29.99") -

-
- - -
- - -

- Language for the store dashboard interface -

-
- - -
- - -

- Primary language for products, emails, and other content -

-
- - -
- - -

- Default language shown to customers visiting your shop -

-
- - -
- -
- -
-

- Languages available in the storefront language selector -

-
- - -
- -
-
-
-
- - -
-
-

Marketplace Integration

-

Configure Letzshop marketplace feed settings

-
-
-
- - - - -
-

Letzshop CSV Feed URLs

-

- Enter the URLs for your Letzshop product feeds in different languages. -

- - -
- -
- - -
-
- - -
- -
- - -
-
- - -
- -
- - -
-
-
- - -
-

Feed Options

- - -
- - -

- Default VAT rate for products without explicit rate -

-
- - -
- - -
- - - {# noqa: FE-008 - Decimal input with 0.1 step and custom @input handler, not suited for number_stepper #} -
- - -

- Higher values boost product visibility (0.0 - 10.0) -

-
- - -
- - -
-
- - -
- -
-
-
-
- - -
-
-

Invoice Settings

-

Configure invoice generation and billing details

-
-
- - -
-
- - -
-
-

Branding & Theme

-

Customize your storefront appearance

-
-
- - -
-
- - -
-
-

Email Settings

-

Configure your email sending settings for customer communications

-
-
- -
-
-
- -
- - - - - - - - -
-

Sender Identity

-
- -
- - -

- Email address that customers will see in their inbox -

-
- - -
- - -

- Name that appears as the sender (e.g., "Your Store Name") -

-
- - -
- - -

- Optional: Where replies should go (defaults to From Email) -

-
-
-
- - -
-

Email Provider

-
- -
- -
- -
-
- - - - - - - - - - - - -
-
- - -
-

Email Signature (Optional)

-
-
- - -
-
-
- - -
- -
- - -
- - - -
-
-
-
- - -
-
-

Domains

-

Manage your storefront domains

-
-
-
- -
-
-

Default Subdomain

-

-
- - Active - -
- - - - -
-
- -
-

- Need a custom domain? Contact support to set up your own domain with SSL. -

-
-
-
-
-
-
- - -
-
-

API & Payments

-

Payment integrations and API access

-
-
-
- -
-
-
- -
-
-

Stripe

-

Payment processing

-
-
- - - -
- - - - -
-
- -
-

- API keys and payment credentials are managed securely. Contact support for changes. -

-
-
-
-
-
-
- - -
-
-

Notification Preferences

-

Control how you receive notifications

-
-
-
- -
-
-

Email Notifications

-

Receive important updates via email

-
- -
- - -
-
-

Order Notifications

-

Get notified when you receive new orders

-
- -
- - -
-
-

Marketing Emails

-

Receive tips, updates, and promotional content

-
- -
- -

- Note: Notification settings are currently display-only. Full notification management coming soon. -

-
-
-
-
-
-{% endblock %} - -{% block extra_scripts %} - -{% endblock %} diff --git a/app/modules/core/tests/integration/test_merchant_menu_routes.py b/app/modules/core/tests/integration/test_merchant_menu_routes.py index 2f726744..376975f9 100644 --- a/app/modules/core/tests/integration/test_merchant_menu_routes.py +++ b/app/modules/core/tests/integration/test_merchant_menu_routes.py @@ -263,34 +263,49 @@ class TestMerchantMenuModuleGating: def test_loyalty_appears_when_module_enabled( self, client, db, menu_auth, menu_merchant, menu_subscription, - menu_loyalty_module, + menu_loyalty_module, menu_platform, ): - """Loyalty section appears when loyalty module is enabled on subscribed platform.""" + """Loyalty items appear under platform section when module is enabled.""" response = client.get(BASE, headers=menu_auth) assert response.status_code == 200 data = response.json() + # Loyalty now appears under a platform-{code} section + platform_section_id = f"platform-{menu_platform.code}" section_ids = {s["id"] for s in data["sections"]} - assert "loyalty" in section_ids + assert platform_section_id in section_ids + # Check loyalty item exists in that platform section + platform_section = next( + s for s in data["sections"] if s["id"] == platform_section_id + ) + item_ids = {i["id"] for i in platform_section["items"]} + assert "loyalty-overview" in item_ids def test_loyalty_hidden_when_module_not_enabled( self, client, db, menu_auth, menu_merchant, menu_subscription, + menu_platform, ): - """Loyalty section is hidden when loyalty module is NOT enabled.""" + """No platform section when no non-core modules are enabled.""" response = client.get(BASE, headers=menu_auth) assert response.status_code == 200 data = response.json() + platform_section_id = f"platform-{menu_platform.code}" section_ids = {s["id"] for s in data["sections"]} - assert "loyalty" not in section_ids + assert platform_section_id not in section_ids def test_loyalty_item_has_correct_route( self, client, db, menu_auth, menu_merchant, menu_subscription, - menu_loyalty_module, + menu_loyalty_module, menu_platform, ): """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") + platform_section_id = f"platform-{menu_platform.code}" + platform_section = next( + s for s in data["sections"] if s["id"] == platform_section_id + ) + overview = next( + i for i in platform_section["items"] if i["id"] == "loyalty-overview" + ) assert overview["url"] == "/merchants/loyalty/overview" @@ -352,7 +367,8 @@ class TestMerchantMenuSubscriptionStatus: 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 + platform_section_id = f"platform-{menu_platform.code}" + assert platform_section_id in section_ids def test_expired_subscription_hides_non_core_modules( self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier, @@ -374,8 +390,9 @@ class TestMerchantMenuSubscriptionStatus: 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 + platform_section_id = f"platform-{menu_platform.code}" + # Platform section should NOT appear because subscription is expired + assert platform_section_id not in section_ids # Core sections always appear assert "main" in section_ids assert "billing" in section_ids @@ -468,9 +485,20 @@ class TestMerchantMenuMultiPlatform: 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 + data = response.json() + section_ids = {s["id"] for s in data["sections"]} + # Loyalty enabled on Platform A appears under platform-a's section + platform_a_section_id = f"platform-{platform_a.code}" + assert platform_a_section_id in section_ids + # Platform B has no non-core modules, so no section + platform_b_section_id = f"platform-{platform_b.code}" + assert platform_b_section_id not in section_ids + # Check loyalty item exists in Platform A section + pa_section = next( + s for s in data["sections"] if s["id"] == platform_a_section_id + ) + item_ids = {i["id"] for i in pa_section["items"]} + assert "loyalty-overview" in item_ids # Core sections always present assert "main" in section_ids assert "billing" in section_ids diff --git a/app/modules/core/tests/unit/test_menu_service.py b/app/modules/core/tests/unit/test_menu_service.py index a4e0629a..dffe15a3 100644 --- a/app/modules/core/tests/unit/test_menu_service.py +++ b/app/modules/core/tests/unit/test_menu_service.py @@ -110,3 +110,62 @@ class TestMenuServiceMerchantRendering: without_ids = {s.id for s in without_loyalty} assert "loyalty" in with_ids assert "loyalty" not in without_ids + + +@pytest.mark.unit +@pytest.mark.core +class TestMenuServiceMerchantByPlatform: + """Test get_merchant_menu_by_platform grouping logic.""" + + def setup_method(self): + self.service = MenuService() + + def test_core_sections_contain_only_core_items(self, db): + """Core sections should only have items from core modules.""" + from app.modules.core.services.menu_discovery_service import ( + menu_discovery_service, + ) + from app.modules.registry import MODULES + + core_codes = {code for code, mod in MODULES.items() if mod.is_core} + + core_sections = menu_discovery_service.get_menu_sections_for_frontend( + db, + FrontendType.MERCHANT, + enabled_module_codes=core_codes, + ) + for section in core_sections: + section.items = [i for i in section.items if i.module_code in core_codes] + + for section in core_sections: + for item in section.items: + assert item.module_code in core_codes, ( + f"Non-core item '{item.id}' (module={item.module_code}) " + f"found in core section '{section.id}'" + ) + + def test_non_core_sections_for_loyalty(self, db): + """Non-core modules like loyalty produce merchant menu sections.""" + from app.modules.core.services.menu_discovery_service import ( + menu_discovery_service, + ) + from app.modules.registry import MODULES + + core_codes = {code for code, mod in MODULES.items() if mod.is_core} + non_core = {"loyalty"} + + sections = menu_discovery_service.get_menu_sections_for_frontend( + db, + FrontendType.MERCHANT, + enabled_module_codes=non_core, + ) + sections = [ + s + for s in sections + if any(i.module_code in non_core for i in s.items) + ] + + assert len(sections) > 0, "Loyalty module should produce merchant menu sections" + for section in sections: + for item in section.items: + assert item.module_code not in core_codes diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index 76e2db39..9e4261f0 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -100,17 +100,17 @@ customers_module = ModuleDefinition( menus={ FrontendType.ADMIN: [ MenuSectionDefinition( - id="storeOps", - label_key="customers.menu.store_operations", + id="userManagement", + label_key="customers.menu.user_management", icon="user-group", - order=40, + order=10, items=[ MenuItemDefinition( id="customers", label_key="customers.menu.customers", icon="user-group", route="/admin/customers", - order=20, + order=40, ), ], ), diff --git a/app/modules/customers/migrations/versions/customers_002_drop_order_stats.py b/app/modules/customers/migrations/versions/customers_002_drop_order_stats.py new file mode 100644 index 00000000..aa5a02ea --- /dev/null +++ b/app/modules/customers/migrations/versions/customers_002_drop_order_stats.py @@ -0,0 +1,35 @@ +"""customers 002 - drop order stats columns (moved to orders module) + +Revision ID: customers_002 +Revises: customers_001 +Create Date: 2026-03-07 +""" +import sqlalchemy as sa + +from alembic import op + +revision = "customers_002" +down_revision = "customers_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("customers", "total_orders") + op.drop_column("customers", "total_spent") + op.drop_column("customers", "last_order_date") + + +def downgrade() -> None: + op.add_column( + "customers", + sa.Column("last_order_date", sa.DateTime(), nullable=True), + ) + op.add_column( + "customers", + sa.Column("total_spent", sa.Numeric(10, 2), nullable=True, server_default="0"), + ) + op.add_column( + "customers", + sa.Column("total_orders", sa.Integer(), nullable=True, server_default="0"), + ) diff --git a/app/modules/customers/models/customer.py b/app/modules/customers/models/customer.py index 50e517dd..ba86af5a 100644 --- a/app/modules/customers/models/customer.py +++ b/app/modules/customers/models/customer.py @@ -10,10 +10,8 @@ from sqlalchemy import ( JSON, Boolean, Column, - DateTime, ForeignKey, Integer, - Numeric, String, ) from sqlalchemy.orm import relationship @@ -41,9 +39,6 @@ class Customer(Base, TimestampMixin): ) # Store-specific ID preferences = Column(JSON, default=dict) marketing_consent = Column(Boolean, default=False) - last_order_date = Column(DateTime) - total_orders = Column(Integer, default=0) - total_spent = Column(Numeric(10, 2), default=0) is_active = Column(Boolean, default=True, nullable=False) # Language preference (NULL = use store storefront_language default) diff --git a/app/modules/customers/routes/pages/store.py b/app/modules/customers/routes/pages/store.py index e1ab6e15..a7ca658f 100644 --- a/app/modules/customers/routes/pages/store.py +++ b/app/modules/customers/routes/pages/store.py @@ -44,3 +44,25 @@ async def store_customers_page( "customers/store/customers.html", get_store_context(request, db, current_user, store_code), ) + + +@router.get( + "/customers/{customer_id}", response_class=HTMLResponse, include_in_schema=False +) +async def store_customer_detail_page( + request: Request, + customer_id: int, + store_code: str = Depends(get_resolved_store_code), + current_user: User = Depends(require_store_page_permission("customers.view")), + db: Session = Depends(get_db), +): + """ + Render customer detail page. + JavaScript loads customer profile and order stats via API. + """ + context = get_store_context(request, db, current_user, store_code) + context["customer_id"] = customer_id + return templates.TemplateResponse( + "customers/store/customer-detail.html", + context, + ) diff --git a/app/modules/customers/schemas/context.py b/app/modules/customers/schemas/context.py index 43916342..f5f4d99e 100644 --- a/app/modules/customers/schemas/context.py +++ b/app/modules/customers/schemas/context.py @@ -7,7 +7,6 @@ avoiding direct database model imports in the API layer. """ from datetime import datetime -from decimal import Decimal from pydantic import BaseModel, ConfigDict @@ -45,11 +44,6 @@ class CustomerContext(BaseModel): marketing_consent: bool = False preferred_language: str | None = None - # Stats (for order placement) - total_orders: int = 0 - total_spent: Decimal = Decimal("0.00") - last_order_date: datetime | None = None - # Status is_active: bool = True @@ -89,9 +83,6 @@ class CustomerContext(BaseModel): phone=customer.phone, marketing_consent=customer.marketing_consent, preferred_language=customer.preferred_language, - total_orders=customer.total_orders or 0, - total_spent=customer.total_spent or Decimal("0.00"), - last_order_date=customer.last_order_date, is_active=customer.is_active, created_at=customer.created_at, updated_at=customer.updated_at, diff --git a/app/modules/customers/schemas/customer.py b/app/modules/customers/schemas/customer.py index 1a694377..7bf610ba 100644 --- a/app/modules/customers/schemas/customer.py +++ b/app/modules/customers/schemas/customer.py @@ -111,9 +111,6 @@ class CustomerResponse(BaseModel): customer_number: str marketing_consent: bool preferred_language: str | None - last_order_date: datetime | None - total_orders: int - total_spent: Decimal is_active: bool created_at: datetime updated_at: datetime @@ -291,10 +288,6 @@ class CustomerStatisticsResponse(BaseModel): total: int = 0 active: int = 0 inactive: int = 0 - with_orders: int = 0 - total_spent: float = 0.0 - total_orders: int = 0 - avg_order_value: float = 0.0 # ============================================================================ @@ -314,9 +307,6 @@ class AdminCustomerItem(BaseModel): customer_number: str marketing_consent: bool = False preferred_language: str | None = None - last_order_date: datetime | None = None - total_orders: int = 0 - total_spent: float = 0.0 is_active: bool = True created_at: datetime updated_at: datetime diff --git a/app/modules/customers/services/admin_customer_service.py b/app/modules/customers/services/admin_customer_service.py index 73415d8e..7c118883 100644 --- a/app/modules/customers/services/admin_customer_service.py +++ b/app/modules/customers/services/admin_customer_service.py @@ -8,7 +8,6 @@ Handles customer operations for admin users across all stores. import logging from typing import Any -from sqlalchemy import func from sqlalchemy.orm import Session from app.modules.customers.exceptions import CustomerNotFoundException @@ -98,9 +97,6 @@ class AdminCustomerService: "customer_number": customer.customer_number, "marketing_consent": customer.marketing_consent, "preferred_language": customer.preferred_language, - "last_order_date": customer.last_order_date, - "total_orders": customer.total_orders, - "total_spent": float(customer.total_spent) if customer.total_spent else 0, "is_active": customer.is_active, "created_at": customer.created_at, "updated_at": customer.updated_at, @@ -134,25 +130,11 @@ class AdminCustomerService: total = query.count() active = query.filter(Customer.is_active == True).count() # noqa: E712 inactive = query.filter(Customer.is_active == False).count() # noqa: E712 - with_orders = query.filter(Customer.total_orders > 0).count() - - # Total spent across all customers - total_spent_result = query.with_entities(func.sum(Customer.total_spent)).scalar() - total_spent = float(total_spent_result) if total_spent_result else 0 - - # Average order value - total_orders_result = query.with_entities(func.sum(Customer.total_orders)).scalar() - total_orders = int(total_orders_result) if total_orders_result else 0 - avg_order_value = total_spent / total_orders if total_orders > 0 else 0 return { "total": total, "active": active, "inactive": inactive, - "with_orders": with_orders, - "total_spent": total_spent, - "total_orders": total_orders, - "avg_order_value": round(avg_order_value, 2), } def get_customer( @@ -195,9 +177,6 @@ class AdminCustomerService: "customer_number": customer.customer_number, "marketing_consent": customer.marketing_consent, "preferred_language": customer.preferred_language, - "last_order_date": customer.last_order_date, - "total_orders": customer.total_orders, - "total_spent": float(customer.total_spent) if customer.total_spent else 0, "is_active": customer.is_active, "created_at": customer.created_at, "updated_at": customer.updated_at, diff --git a/app/modules/customers/services/customer_service.py b/app/modules/customers/services/customer_service.py index 32e8158b..fc229604 100644 --- a/app/modules/customers/services/customer_service.py +++ b/app/modules/customers/services/customer_service.py @@ -431,26 +431,6 @@ class CustomerService: return customer - def update_customer_stats( - self, db: Session, customer_id: int, order_total: float - ) -> None: - """ - Update customer statistics after order. - - Args: - db: Database session - customer_id: Customer ID - order_total: Order total amount - """ - customer = db.query(Customer).filter(Customer.id == customer_id).first() - - if customer: - customer.total_orders += 1 - customer.total_spent += order_total - customer.last_order_date = datetime.utcnow() - - logger.debug(f"Updated stats for customer {customer.email}") - def _generate_customer_number( self, db: Session, store_id: int, store_code: str ) -> str: diff --git a/app/modules/customers/static/admin/js/customers.js b/app/modules/customers/static/admin/js/customers.js index 4e7c482a..04fb13b8 100644 --- a/app/modules/customers/static/admin/js/customers.js +++ b/app/modules/customers/static/admin/js/customers.js @@ -27,11 +27,7 @@ function adminCustomers() { stats: { total: 0, active: 0, - inactive: 0, - with_orders: 0, - total_spent: 0, - total_orders: 0, - avg_order_value: 0 + inactive: 0 }, // Pagination (standard structure matching pagination macro) @@ -375,17 +371,6 @@ function adminCustomers() { } }, - /** - * Format currency for display - */ - formatCurrency(amount) { - if (amount == null) return '-'; - return new Intl.NumberFormat('de-DE', { - style: 'currency', - currency: 'EUR' - }).format(amount); - }, - /** * Format date for display */ diff --git a/app/modules/customers/static/store/js/customer-detail.js b/app/modules/customers/static/store/js/customer-detail.js new file mode 100644 index 00000000..82f46988 --- /dev/null +++ b/app/modules/customers/static/store/js/customer-detail.js @@ -0,0 +1,150 @@ +// app/modules/customers/static/store/js/customer-detail.js +/** + * Store customer detail page logic. + * Loads customer profile, order stats, and recent orders from existing APIs. + */ + +const customerDetailLog = window.LogConfig?.createLogger('customerDetail') || console; + +function storeCustomerDetail() { + return { + // Inherit base layout state + ...data(), + + // Page identifier + currentPage: 'customers', + + // Loading states + loading: true, + error: '', + + // Data + customerId: window.customerDetailData?.customerId, + customer: null, + orderStats: { + total_orders: 0, + total_spent_cents: 0, + last_order_date: null, + first_order_date: null + }, + recentOrders: [], + + // Computed + get customerName() { + if (this.customer?.first_name && this.customer?.last_name) { + return `${this.customer.first_name} ${this.customer.last_name}`; + } + return this.customer?.email || 'Unknown'; + }, + + async init() { + try { + // Load i18n translations + await I18n.loadModule('customers'); + + customerDetailLog.info('Customer detail init, id:', this.customerId); + + // Call parent init to set storeCode from URL + const parentInit = data().init; + if (parentInit) { + await parentInit.call(this); + } + + // Load all data in parallel + await Promise.all([ + this.loadCustomer(), + this.loadOrderStats(), + this.loadRecentOrders() + ]); + + customerDetailLog.info('Customer detail loaded'); + } catch (error) { + customerDetailLog.error('Init failed:', error); + this.error = 'Failed to load customer details'; + } finally { + this.loading = false; + } + }, + + /** + * Load customer profile + */ + async loadCustomer() { + try { + const response = await apiClient.get(`/store/customers/${this.customerId}`); + this.customer = response; + } catch (error) { + customerDetailLog.error('Failed to load customer:', error); + this.error = error.message || 'Customer not found'; + } + }, + + /** + * Load order statistics from orders module + */ + async loadOrderStats() { + try { + const response = await apiClient.get(`/store/customers/${this.customerId}/order-stats`); + this.orderStats = response; + } catch (error) { + customerDetailLog.warn('Failed to load order stats:', error); + // Non-fatal — page still works without stats + } + }, + + /** + * Load recent orders from orders module + */ + async loadRecentOrders() { + try { + const response = await apiClient.get(`/store/customers/${this.customerId}/orders?limit=5`); + this.recentOrders = response.orders || []; + } catch (error) { + customerDetailLog.warn('Failed to load recent orders:', error); + // Non-fatal + } + }, + + /** + * Get customer initials for avatar + */ + getInitials() { + const first = this.customer?.first_name || ''; + const last = this.customer?.last_name || ''; + return (first.charAt(0) + last.charAt(0)).toUpperCase() || '?'; + }, + + /** + * Navigate to send message + */ + messageCustomer() { + window.location.href = `/store/${this.storeCode}/messages?customer=${this.customerId}`; + }, + + /** + * Format date for display + */ + formatDate(dateStr) { + if (!dateStr) return '-'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; + return new Date(dateStr).toLocaleDateString(locale, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }, + + /** + * Format price (cents to currency) + */ + formatPrice(cents) { + if (!cents && cents !== 0) return '-'; + const locale = window.STORE_CONFIG?.locale || 'en-GB'; + const currency = window.STORE_CONFIG?.currency || 'EUR'; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency + }).format(cents / 100); + } + }; +} diff --git a/app/modules/customers/static/store/js/customers.js b/app/modules/customers/static/store/js/customers.js index 703034b8..8f4040f4 100644 --- a/app/modules/customers/static/store/js/customers.js +++ b/app/modules/customers/static/store/js/customers.js @@ -48,9 +48,7 @@ function storeCustomers() { // Modal states showDetailModal: false, - showOrdersModal: false, selectedCustomer: null, - customerOrders: [], // Debounce timer searchTimeout: null, @@ -227,25 +225,6 @@ function storeCustomers() { } }, - /** - * View customer orders - */ - async viewCustomerOrders(customer) { - this.loading = true; - try { - const response = await apiClient.get(`/store/customers/${customer.id}/orders`); - this.selectedCustomer = customer; - this.customerOrders = response.orders || []; - this.showOrdersModal = true; - storeCustomersLog.info('Loaded customer orders:', customer.id, this.customerOrders.length); - } catch (error) { - storeCustomersLog.error('Failed to load customer orders:', error); - Utils.showToast(error.message || I18n.t('customers.messages.failed_to_load_customer_orders'), 'error'); - } finally { - this.loading = false; - } - }, - /** * Send message to customer */ @@ -275,19 +254,6 @@ function storeCustomers() { }); }, - /** - * Format price for display - */ - formatPrice(cents) { - if (!cents && cents !== 0) return '-'; - const locale = window.STORE_CONFIG?.locale || 'en-GB'; - const currency = window.STORE_CONFIG?.currency || 'EUR'; - return new Intl.NumberFormat(locale, { - style: 'currency', - currency: currency - }).format(cents / 100); - }, - /** * Pagination: Previous page */ diff --git a/app/modules/customers/templates/customers/admin/customers.html b/app/modules/customers/templates/customers/admin/customers.html index aff73f51..40ae0f10 100644 --- a/app/modules/customers/templates/customers/admin/customers.html +++ b/app/modules/customers/templates/customers/admin/customers.html @@ -29,7 +29,7 @@ {{ error_state('Error loading customers') }} -
+
@@ -59,36 +59,6 @@

- - -
-
- -
-
-

- With Orders -

-

- 0 -

-
-
- - -
-
- -
-
-

- Total Revenue -

-

- 0 -

-
-
@@ -134,8 +104,6 @@ Customer Store Customer # - Orders - Total Spent Status Joined Actions @@ -145,7 +113,7 @@