feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
CI / ruff (push) Successful in 12s
CI / pytest (push) Successful in 50m57s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 40s
CI / deploy (push) Successful in 51s

- Fix platform-grouped merchant sidebar menu with core items at root level
- Add merchant store management (detail page, create store, team page)
- Fix store settings 500 error by removing dead stripe/API tab
- Move onboarding translations to module-owned locale files
- Fix onboarding banner i18n with server-side rendering + context inheritance
- Refactor login language selectors to use languageSelector() function (LANG-002)
- Move HTTPException handling to global exception handler in merchant routes (API-003)
- Add language selector to all login pages and portal headers
- Fix customer module: drop order stats from customer model, add to orders module
- Fix admin menu config visibility for super admin platform context
- Fix storefront auth and layout issues
- Add missing i18n translations for onboarding steps (en/fr/de/lb)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 23:48:25 +01:00
parent f141cc4e6a
commit a77a8a3a98
113 changed files with 3741 additions and 2923 deletions

View File

@@ -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

View File

@@ -96,6 +96,7 @@ analytics_module = ModuleDefinition(
icon="chart-bar",
route="/store/{store_code}/analytics",
order=20,
requires_permission="analytics.view",
),
],
),

View File

@@ -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;

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,
),
],
),
],

View File

@@ -72,7 +72,6 @@
"dashboard": "Dashboard",
"platform_settings": "Plattform-Einstellungen",
"general": "Allgemein",
"my_menu": "Mein Menü",
"account_settings": "Kontoeinstellungen",
"profile": "Profil",
"settings": "Einstellungen"

View File

@@ -92,7 +92,6 @@
"dashboard": "Dashboard",
"platform_settings": "Platform Settings",
"general": "General",
"my_menu": "My Menu",
"account_settings": "Account Settings",
"profile": "Profile",
"settings": "Settings"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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.",
)
# ========================================================================

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)
# ============================================================================

View File

@@ -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,

View File

@@ -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:

View File

@@ -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:

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;
}
}
};
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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',

View File

@@ -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') }}
<!-- Info Box -->
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-200">
This configures <strong>your personal</strong> admin sidebar menu. These settings only affect your view.
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
To configure menus for platform admins or stores, go to <a href="/admin/platforms" class="underline hover:no-underline">Platforms</a> and select a platform's Menu Configuration.
</p>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 mb-6 md:grid-cols-3">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Items</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.total_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Visible</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.visible_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-600">
<span x-html="$icon('eye-off', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Hidden</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.hidden_items || 0"></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
Toggle visibility for menu items. Mandatory items cannot be hidden.
</p>
<div class="flex gap-2">
<button
@click="showShowAllModal = true"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
Show All
</button>
<button
@click="showHideAllModal = true"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye-off', 'w-4 h-4 mr-2')"></span>
Hide All
</button>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading menu configuration...</span>
</div>
<!-- Menu Items by Section -->
<div x-show="!loading" class="space-y-6">
<template x-for="section in groupedItems" :key="section.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<!-- Section Header -->
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="section.label || 'Main'"></h3>
<span
x-show="section.isSuperAdminOnly"
class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
>
Super Admin Only
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="`${section.visibleCount}/${section.items.length} visible`"></span>
</div>
</div>
<!-- Section Items -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="item in section.items" :key="item.id">
<div class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<div class="flex items-center">
<span x-html="$icon(item.icon, 'w-5 h-5 text-gray-400 mr-3')"></span>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="item.label"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="item.url"></p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Mandatory Badge -->
<span
x-show="item.is_mandatory"
class="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
Mandatory
</span>
<!-- Toggle Switch -->
<button
@click="toggleVisibility(item)"
:disabled="item.is_mandatory || saving"
:class="{
'bg-purple-600': item.is_visible,
'bg-gray-200 dark:bg-gray-600': !item.is_visible,
'opacity-50 cursor-not-allowed': item.is_mandatory
}"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
role="switch"
:aria-checked="item.is_visible"
>
<span
:class="{
'translate-x-5': item.is_visible,
'translate-x-0': !item.is_visible
}"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="groupedItems.length === 0" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-8 text-center">
<span x-html="$icon('view-grid', 'w-12 h-12 mx-auto text-gray-400')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No menu items available.</p>
</div>
</div>
<!-- Confirmation Modals -->
{{ 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 %}
<script defer src="{{ url_for('core_static', path='admin/js/my-menu-config.js') }}"></script>
{% endblock %}

View File

@@ -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 %}
<!-- Email Settings Warning -->

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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,
),
],
),

View File

@@ -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"),
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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
*/

View File

@@ -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);
}
};
}

View File

@@ -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
*/

View File

@@ -29,7 +29,7 @@
{{ error_state('Error loading customers') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Card: Total Customers -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
@@ -59,36 +59,6 @@
</p>
</div>
</div>
<!-- Card: With Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('shopping-bag', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
With Orders
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.with_orders || 0">
0
</p>
</div>
</div>
<!-- Card: Total Spent -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Revenue
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatCurrency(stats.total_spent || 0)">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters -->
@@ -134,8 +104,6 @@
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Store</th>
<th class="px-4 py-3">Customer #</th>
<th class="px-4 py-3">Orders</th>
<th class="px-4 py-3">Total Spent</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Actions</th>
@@ -145,7 +113,7 @@
<!-- Loading state -->
<template x-if="loadingCustomers && customers.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading customers...</p>
</td>
@@ -155,7 +123,7 @@
<!-- Empty state -->
<template x-if="!loadingCustomers && customers.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('user-group', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No customers found</p>
<p class="text-sm mt-1">Try adjusting your search or filters</p>
@@ -189,16 +157,6 @@
<span class="font-mono text-xs" x-text="customer.customer_number"></span>
</td>
<!-- Orders -->
<td class="px-4 py-3 text-sm">
<span x-text="customer.total_orders || 0"></span>
</td>
<!-- Total Spent -->
<td class="px-4 py-3 text-sm">
<span x-text="formatCurrency(customer.total_spent || 0)"></span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span

View File

@@ -0,0 +1,178 @@
{# app/templates/store/customer-detail.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Customer Details{% endblock %}
{% block alpine_data %}storeCustomerDetail(){% endblock %}
{% block content %}
<!-- Back Button -->
<div class="mb-6">
<a :href="`/store/${storeCode}/customers`"
class="inline-flex items-center text-sm text-gray-600 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-1')"></span>
Back to Customers
</a>
</div>
{% call page_header_flex(title='Customer Details', subtitle='View customer profile and order history') %}
<div class="flex items-center gap-2" x-show="!loading && customer">
<span
class="px-3 py-1 text-sm font-semibold rounded-full"
:class="customer?.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="customer?.is_active ? 'Active' : 'Inactive'"
></span>
</div>
{% endcall %}
{{ loading_state('Loading customer details...') }}
{{ error_state('Error loading customer') }}
<!-- Main Content -->
<div x-show="!loading && !error && customer" class="grid gap-6 lg:grid-cols-3">
<!-- Left Column: Profile -->
<div class="lg:col-span-2 space-y-6">
<!-- Profile Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Profile</h3>
</div>
<div class="p-6">
<div class="flex items-center mb-6">
<div class="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-4">
<span class="text-xl font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials()"></span>
</div>
<div>
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="customerName"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="customer?.email"></p>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Customer #</p>
<p class="text-sm font-mono font-medium text-gray-700 dark:text-gray-200" x-text="customer?.customer_number || '-'"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Phone</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="customer?.phone || '-'"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Joined</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="formatDate(customer?.created_at)"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Language</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="(customer?.preferred_language || 'Default').toUpperCase()"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Marketing</p>
<p class="text-sm font-medium" :class="customer?.marketing_consent ? 'text-green-600' : 'text-gray-500'" x-text="customer?.marketing_consent ? 'Opted in' : 'Opted out'"></p>
</div>
</div>
</div>
</div>
<!-- Recent Orders -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Recent Orders</h3>
<a :href="`/store/${storeCode}/orders?customer_id=${customerId}`"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
x-show="recentOrders.length > 0">
View All Orders
<span x-html="$icon('arrow-right', 'w-4 h-4 inline ml-1')"></span>
</a>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="order in recentOrders" :key="order.id">
<a :href="`/store/${storeCode}/orders/${order.id}`" class="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div>
<p class="font-mono font-semibold text-gray-800 dark:text-gray-200" x-text="order.order_number || `#${order.id}`"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(order.created_at || order.order_date)"></p>
</div>
<div class="text-right">
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="formatPrice(order.total_amount_cents)"></p>
<span
class="text-xs px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'delivered' || order.status === 'completed',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': order.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'processing' || order.status === 'shipped',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': order.status === 'cancelled',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !['delivered','completed','pending','processing','shipped','cancelled'].includes(order.status)
}"
x-text="order.status"
></span>
</div>
</a>
</template>
<div x-show="recentOrders.length === 0" class="p-6 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('shopping-bag', 'w-8 h-8 mx-auto mb-2 text-gray-300')"></span>
<p>No orders yet</p>
</div>
</div>
</div>
</div>
<!-- Right Column: Order Stats & Actions -->
<div class="space-y-6">
<!-- Order Stats Cards -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Order Statistics</h3>
</div>
<div class="p-6 space-y-4">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Total Orders</span>
<span class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.total_orders || 0"></span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Total Spent</span>
<span class="text-lg font-semibold text-purple-600 dark:text-purple-400" x-text="formatPrice(orderStats.total_spent_cents || 0)"></span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Avg Order Value</span>
<span class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatPrice(orderStats.total_orders ? Math.round(orderStats.total_spent_cents / orderStats.total_orders) : 0)"></span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Last Order</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="orderStats.last_order_date ? formatDate(orderStats.last_order_date) : 'Never'"></span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Actions</h3>
</div>
<div class="p-4 space-y-2">
<a :href="`/store/${storeCode}/orders?customer_id=${customerId}`"
class="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300 dark:hover:bg-purple-800">
<span x-html="$icon('shopping-bag', 'w-4 h-4 mr-2')"></span>
View All Orders
</a>
<button
@click="messageCustomer()"
class="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-green-600 bg-green-100 rounded-lg hover:bg-green-200 dark:bg-green-900 dark:text-green-300 dark:hover:bg-green-800">
<span x-html="$icon('chat-bubble-left-right', 'w-4 h-4 mr-2')"></span>
Send Message
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
window.customerDetailData = {
customerId: {{ customer_id }}
};
</script>
<script defer src="{{ url_for('customers_static', path='store/js/customer-detail.js') }}"></script>
{% endblock %}

View File

@@ -107,7 +107,6 @@
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Email</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Orders</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
@@ -130,8 +129,6 @@
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
<!-- Joined -->
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
<!-- Orders -->
<td class="px-4 py-3 text-sm font-semibold" x-text="customer.order_count || 0"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
@@ -142,13 +139,6 @@
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="viewCustomerOrders(customer)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="View Orders"
>
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
</button>
<button
@click="messageCustomer(customer)"
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
@@ -162,7 +152,7 @@
</template>
<!-- Empty State -->
<tr x-show="customers.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="4" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No customers found</p>
@@ -199,12 +189,12 @@
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
<p class="text-gray-500 dark:text-gray-400">Customer #</p>
<p class="font-medium font-mono text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.customer_number || '-'"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></p>
<p class="text-gray-500 dark:text-gray-400">Status</p>
<p class="font-medium" :class="selectedCustomer?.is_active ? 'text-green-600' : 'text-red-600'" x-text="selectedCustomer?.is_active ? 'Active' : 'Inactive'"></p>
</div>
</div>
</div>
@@ -212,55 +202,11 @@
<button @click="showDetailModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Close
</button>
<button @click="messageCustomer(selectedCustomer); showDetailModal = false" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Send Message
</button>
<a :href="`/store/${storeCode}/customers/${selectedCustomer?.id}`" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
View Full Profile
</a>
</div>
{% endcall %}
<!-- Customer Orders Modal -->
<div x-show="showOrdersModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div class="w-full max-w-2xl bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showOrdersModal = false">
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
Orders for <span x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim()"></span>
</h3>
<button @click="showOrdersModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<div class="p-4 max-h-96 overflow-y-auto">
<template x-if="customerOrders.length === 0">
<p class="text-center text-gray-500 dark:text-gray-400 py-8">No orders found for this customer</p>
</template>
<template x-for="order in customerOrders" :key="order.id">
<div class="flex items-center justify-between p-3 border-b dark:border-gray-700 last:border-0">
<div>
<p class="font-mono font-semibold text-gray-800 dark:text-gray-200" x-text="order.order_number || `#${order.id}`"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(order.created_at)"></p>
</div>
<div class="text-right">
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="formatPrice(order.total)"></p>
<span
class="text-xs px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'completed',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': order.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'processing'
}"
x-text="order.status"
></span>
</div>
</div>
</template>
</div>
<div class="flex justify-end p-4 border-t dark:border-gray-700">
<button @click="showOrdersModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Close
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}

View File

@@ -1,11 +1,11 @@
{# app/templates/storefront/account/forgot-password.html #}
{# standalone #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="{{ current_language|default('fr') }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forgot Password - {{ store.name }}</title>
<title>{{ _("auth.forgot_password") }} - {{ store.name }}</title>
<!-- Fonts: Local fallback + Google Fonts -->
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
@@ -57,7 +57,6 @@
<div class="text-6xl mb-4">🔐</div>
{% endif %}
<h2 class="text-2xl font-bold text-white mb-2">{{ store.name }}</h2>
<p class="text-white opacity-90">Reset your password</p>
</div>
</div>
@@ -68,11 +67,11 @@
<template x-if="!emailSent">
<div>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Forgot Password
{{ _("auth.reset_password") }}
</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
Enter your email address and we'll send you a link to reset your password.
{{ _("auth.reset_password_desc") }}
</p>
<!-- Error Message -->
@@ -84,14 +83,14 @@
<!-- Forgot Password Form -->
<form @submit.prevent="handleSubmit">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="email"
:disabled="loading"
@input="clearErrors"
type="email"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.email }"
placeholder="your@email.com"
placeholder="{{ _('auth.email_placeholder') }}"
autocomplete="email"
required />
<span x-show="errors.email" x-text="errors.email"
@@ -100,10 +99,13 @@
<button type="submit" :disabled="loading"
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Send Reset Link</span>
<span x-show="!loading">{{ _("auth.send_reset_link") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Sending...
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ _("auth.sending") }}
</span>
</button>
</form>
@@ -114,24 +116,25 @@
<template x-if="emailSent">
<div class="text-center">
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Check Your Email
{{ _("auth.check_email") }}
</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
We've sent a password reset link to <strong x-text="email"></strong>.
Please check your inbox and click the link to reset your password.
{{ _("auth.reset_link_sent") }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Didn't receive the email? Check your spam folder or
{{ _("auth.didnt_receive_email") }}
<button @click="emailSent = false"
class="font-medium hover:underline"
style="color: var(--color-primary);">
try again
{{ _("auth.try_again") }}
</button>
</p>
</div>
@@ -140,19 +143,34 @@
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.remember_password") }}</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}account/login">
Sign in
{{ _("auth.sign_in") }}
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}">
← Continue shopping
&larr; {{ _("auth.continue_shopping") }}
</a>
</p>
<!-- Language selector -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>
</div>
@@ -164,6 +182,22 @@
<!-- Forgot Password Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
},
};
}
function forgotPassword() {
return {
// Data

View File

@@ -64,7 +64,7 @@
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Customer Login
{{ _("auth.customer_login") }}
</h1>
<!-- Success Message (after registration) -->
@@ -82,14 +82,14 @@
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="credentials.email"
:disabled="loading"
@input="clearAllErrors"
type="email"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.email }"
placeholder="your@email.com"
placeholder="{{ _('auth.email_placeholder') }}"
autocomplete="email"
required />
<span x-show="errors.email" x-text="errors.email"
@@ -97,7 +97,7 @@
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
<div class="relative">
<input x-model="credentials.password"
:disabled="loading"
@@ -105,7 +105,7 @@
:type="showPassword ? 'text' : 'password'"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.password }"
placeholder="Enter your password"
placeholder="{{ _('auth.password_placeholder') }}"
autocomplete="current-password"
required />
<button type="button"
@@ -125,21 +125,21 @@
x-model="rememberMe"
class="form-checkbox focus-primary focus:outline-none"
style="color: var(--color-primary);">
<span class="ml-2 text-gray-700 dark:text-gray-400">Remember me</span>
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
</label>
<a href="{{ base_url }}account/forgot-password"
class="text-sm font-medium hover:underline"
style="color: var(--color-primary);">
Forgot password?
{{ _("auth.forgot_password") }}
</a>
</div>
<button type="submit" :disabled="loading"
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Sign in</span>
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Signing in...
{{ _("auth.signing_in") }}
</span>
</button>
</form>
@@ -147,19 +147,34 @@
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Don't have an account?</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}account/register">
Create an account
{{ _("auth.create_account") }}
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}">
← Continue shopping
&larr; {{ _("auth.continue_shopping") }}
</a>
</p>
<!-- Language selector (always show all platform languages on login page) -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>
</div>
@@ -171,6 +186,22 @@
<!-- Login Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
},
};
}
function customerLogin() {
return {
// Data

View File

@@ -65,7 +65,7 @@
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Create Account
{{ _("auth.create_account_title") }}
</h1>
<!-- Success Message -->
@@ -85,7 +85,7 @@
<!-- First Name -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">
First Name <span class="text-red-600">*</span>
{{ _("auth.first_name") }} <span class="text-red-600">*</span>
</span>
<input x-model="formData.first_name"
:disabled="loading"
@@ -102,7 +102,7 @@
<!-- Last Name -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Last Name <span class="text-red-600">*</span>
{{ _("auth.last_name") }} <span class="text-red-600">*</span>
</span>
<input x-model="formData.last_name"
:disabled="loading"
@@ -119,7 +119,7 @@
<!-- Email -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Email Address <span class="text-red-600">*</span>
{{ _("common.email") }} <span class="text-red-600">*</span>
</span>
<input x-model="formData.email"
:disabled="loading"
@@ -136,7 +136,7 @@
<!-- Phone (Optional) -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Phone Number</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.phone_number") }}</span>
<input x-model="formData.phone"
:disabled="loading"
type="tel"
@@ -147,7 +147,7 @@
<!-- Password -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Password <span class="text-red-600">*</span>
{{ _("auth.password") }} <span class="text-red-600">*</span>
</span>
<div class="relative">
<input x-model="formData.password"
@@ -166,7 +166,7 @@
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must contain at least 8 characters, one letter, and one number
{{ _("auth.password_requirements") }}
</p>
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
@@ -175,7 +175,7 @@
<!-- Confirm Password -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Confirm Password <span class="text-red-600">*</span>
{{ _("auth.confirm_password") }} <span class="text-red-600">*</span>
</span>
<input x-model="confirmPassword"
:disabled="loading"
@@ -198,16 +198,16 @@
class="form-checkbox focus-primary focus:outline-none mt-1"
style="color: var(--color-primary);">
<label for="marketingConsent" class="ml-2 text-sm text-gray-600 dark:text-gray-400">
I'd like to receive news and special offers
{{ _("auth.marketing_consent") }}
</label>
</div>
<button type="submit" :disabled="loading"
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Create Account</span>
<span x-show="!loading">{{ _("auth.create_account_title") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Creating account...
{{ _("auth.creating_account") }}
</span>
</button>
</form>
@@ -215,13 +215,28 @@
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Already have an account?</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.already_have_account") }}</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}account/login">
Sign in instead
{{ _("auth.sign_in_instead") }}
</a>
</p>
<!-- Language selector (always show all platform languages on login/register pages) -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>
</div>
@@ -233,6 +248,22 @@
<!-- Registration Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
},
};
}
function customerRegistration() {
return {
// Data

View File

@@ -3,8 +3,6 @@
Unit tests for AdminCustomerService.
"""
from decimal import Decimal
import pytest
from app.modules.customers.exceptions import CustomerNotFoundException
@@ -18,16 +16,6 @@ def admin_customer_service():
return AdminCustomerService()
@pytest.fixture
def customer_with_orders(db, test_store, test_customer):
"""Create a customer with order data."""
test_customer.total_orders = 5
test_customer.total_spent = Decimal("250.00")
db.commit()
db.refresh(test_customer)
return test_customer
@pytest.fixture
def multiple_customers(db, test_store):
"""Create multiple customers for testing."""
@@ -41,8 +29,6 @@ def multiple_customers(db, test_store):
last_name=f"Last{i}",
customer_number=f"CUST-00{i}",
is_active=(i % 2 == 0), # Alternate active/inactive
total_orders=i,
total_spent=Decimal(str(i * 100)),
)
db.add(customer) # noqa: PERF006
customers.append(customer)
@@ -165,10 +151,6 @@ class TestAdminCustomerServiceStats:
assert stats["total"] == 0
assert stats["active"] == 0
assert stats["inactive"] == 0
assert stats["with_orders"] == 0
assert stats["total_spent"] == 0
assert stats["total_orders"] == 0
assert stats["avg_order_value"] == 0
def test_get_customer_stats_with_data(
self, db, admin_customer_service, multiple_customers
@@ -179,12 +161,6 @@ class TestAdminCustomerServiceStats:
assert stats["total"] == 5
assert stats["active"] == 3 # 0, 2, 4
assert stats["inactive"] == 2 # 1, 3
# with_orders = customers with total_orders > 0 (1, 2, 3, 4 = 4 customers)
assert stats["with_orders"] == 4
# total_spent = 0 + 100 + 200 + 300 + 400 = 1000
assert stats["total_spent"] == 1000.0
# total_orders = 0 + 1 + 2 + 3 + 4 = 10
assert stats["total_orders"] == 10
def test_get_customer_stats_by_store(
self, db, admin_customer_service, test_customer, test_store
@@ -194,16 +170,6 @@ class TestAdminCustomerServiceStats:
assert stats["total"] == 1
def test_get_customer_stats_avg_order_value(
self, db, admin_customer_service, customer_with_orders
):
"""Test average order value calculation."""
stats = admin_customer_service.get_customer_stats(db)
# total_spent = 250, total_orders = 5
# avg = 250 / 5 = 50
assert stats["avg_order_value"] == 50.0
@pytest.mark.unit
class TestAdminCustomerServiceGetCustomer:

View File

@@ -49,8 +49,6 @@ class TestCustomerModel:
assert customer.is_active is True # Default
assert customer.marketing_consent is False # Default
assert customer.total_orders == 0 # Default
assert customer.total_spent == 0 # Default
def test_customer_full_name_property(self, db, test_store):
"""Test Customer full_name computed property."""

View File

@@ -149,7 +149,6 @@ class TestCustomerResponseSchema:
def test_from_dict(self):
"""Test creating response from dict."""
from datetime import datetime
from decimal import Decimal
data = {
"id": 1,
@@ -161,9 +160,6 @@ class TestCustomerResponseSchema:
"customer_number": "CUST001",
"marketing_consent": False,
"preferred_language": "fr",
"last_order_date": None,
"total_orders": 5,
"total_spent": Decimal("500.00"),
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now(),
@@ -171,7 +167,7 @@ class TestCustomerResponseSchema:
response = CustomerResponse(**data)
assert response.id == 1
assert response.customer_number == "CUST001"
assert response.total_orders == 5
assert response.preferred_language == "fr"
@pytest.mark.unit

View File

@@ -38,7 +38,6 @@ MANDATORY_MENU_ITEMS = {
"stores",
"admin-users",
"settings",
"my-menu", # Super admin menu config - must always be accessible
}),
FrontendType.STORE: frozenset({
"dashboard", # Default landing page after login

View File

@@ -90,5 +90,11 @@
"view_rewards_desc": "Prämien und Einlösungen anzeigen",
"manage_rewards": "Prämien verwalten",
"manage_rewards_desc": "Treueprämien erstellen und verwalten"
},
"onboarding": {
"create_program": {
"title": "Treueprogramm erstellen",
"description": "Erstellen Sie Ihr erstes Stempel- oder Punkteprogramm"
}
}
}

View File

@@ -90,5 +90,11 @@
"statistics": "Statistics",
"overview": "Overview",
"settings": "Settings"
},
"onboarding": {
"create_program": {
"title": "Create a loyalty program",
"description": "Set up your first stamp or points program"
}
}
}

View File

@@ -90,5 +90,11 @@
"view_rewards_desc": "Voir les récompenses et les échanges",
"manage_rewards": "Gérer les récompenses",
"manage_rewards_desc": "Créer et gérer les récompenses de fidélité"
},
"onboarding": {
"create_program": {
"title": "Créer un programme de fidélité",
"description": "Créez votre premier programme de tampons ou de points"
}
}
}

View File

@@ -90,5 +90,11 @@
"view_rewards_desc": "Belounungen an Aléisunge kucken",
"manage_rewards": "Beloununge verwalten",
"manage_rewards_desc": "Treiheet-Belounungen erstellen a verwalten"
},
"onboarding": {
"create_program": {
"title": "Treieprogramm erstellen",
"description": "Erstellt Äert éischt Stempel- oder Punkteprogramm"
}
}
}

View File

@@ -26,8 +26,8 @@ class LoyaltyOnboardingProvider:
return [
OnboardingStepDefinition(
key="loyalty.create_program",
title_key="onboarding.loyalty.create_program.title",
description_key="onboarding.loyalty.create_program.description",
title_key="loyalty.onboarding.create_program.title",
description_key="loyalty.onboarding.create_program.description",
icon="gift",
route_template="/store/{store_code}/loyalty/programs",
order=300,

View File

@@ -32,6 +32,9 @@ function merchantLoyaltySettings() {
if (window._merchantLoyaltySettingsInitialized) return;
window._merchantLoyaltySettingsInitialized = true;
// Load sidebar menu (from base data())
this.loadMenuConfig();
await this.loadSettings();
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
},

View File

@@ -147,6 +147,7 @@ marketplace_module = ModuleDefinition(
icon="download",
route="/store/{store_code}/marketplace",
order=30,
requires_permission="marketplace.view_integration",
),
],
),
@@ -162,6 +163,7 @@ marketplace_module = ModuleDefinition(
icon="external-link",
route="/store/{store_code}/letzshop",
order=20,
requires_permission="marketplace.view_integration",
),
],
),

View File

@@ -91,5 +91,15 @@
"manage_integration_desc": "Marktplatz-Integration konfigurieren",
"sync_products": "Produkte synchronisieren",
"sync_products_desc": "Produkte mit dem Marktplatz synchronisieren"
},
"onboarding": {
"connect_api": {
"title": "Letzshop-API verbinden",
"description": "Konfigurieren Sie Ihren API-Schlüssel für die Synchronisation mit Letzshop"
},
"import_products": {
"title": "Produkte importieren",
"description": "Importieren Sie Ihren Produktkatalog von Letzshop"
}
}
}

View File

@@ -1,109 +1,119 @@
{
"menu": {
"marketplace": "Marketplace",
"letzshop": "Letzshop",
"products_inventory": "Products & Inventory",
"marketplace_import": "Marketplace Import",
"sales_orders": "Sales & Orders",
"letzshop_orders": "Letzshop Orders"
},
"permissions": {
"view_integration": "View Integration",
"view_integration_desc": "View marketplace integration settings",
"manage_integration": "Manage Integration",
"manage_integration_desc": "Configure marketplace integration",
"sync_products": "Sync Products",
"sync_products_desc": "Synchronize products with marketplace"
},
"marketplace": {
"title": "Marketplace",
"import": "Import",
"export": "Export",
"sync": "Sync",
"source": "Source",
"source_url": "Source URL",
"import_products": "Import Products",
"start_import": "Start Import",
"importing": "Importing...",
"import_complete": "Import Complete",
"import_failed": "Import Failed",
"import_history": "Import History",
"job_id": "Job ID",
"started_at": "Started At",
"completed_at": "Completed At",
"duration": "Duration",
"imported_count": "Imported",
"error_count": "Errors",
"total_processed": "Total Processed",
"progress": "Progress",
"no_import_jobs": "No import jobs yet",
"start_first_import": "Start your first import using the form above"
},
"letzshop": {
"title": "Letzshop Integration",
"connection": "Connection",
"credentials": "Credentials",
"api_key": "API Key",
"api_endpoint": "API Endpoint",
"auto_sync": "Auto Sync",
"sync_interval": "Sync Interval",
"every_hour": "Every hour",
"every_day": "Every day",
"test_connection": "Test Connection",
"save_credentials": "Save Credentials",
"connection_success": "Connection successful",
"connection_failed": "Connection failed",
"last_sync": "Last Sync",
"sync_status": "Sync Status",
"import_orders": "Import Orders",
"export_products": "Export Products",
"no_credentials": "Configure your API key in Settings to get started",
"carriers": {
"dhl": "DHL",
"ups": "UPS",
"fedex": "FedEx",
"dpd": "DPD",
"gls": "GLS",
"post_luxembourg": "Post Luxembourg",
"other": "Other"
"menu": {
"marketplace": "Marketplace",
"letzshop": "Letzshop",
"products_inventory": "Products & Inventory",
"marketplace_import": "Marketplace Import",
"sales_orders": "Sales & Orders",
"letzshop_orders": "Letzshop Orders"
},
"permissions": {
"view_integration": "View Integration",
"view_integration_desc": "View marketplace integration settings",
"manage_integration": "Manage Integration",
"manage_integration_desc": "Configure marketplace integration",
"sync_products": "Sync Products",
"sync_products_desc": "Synchronize products with marketplace"
},
"marketplace": {
"title": "Marketplace",
"import": "Import",
"export": "Export",
"sync": "Sync",
"source": "Source",
"source_url": "Source URL",
"import_products": "Import Products",
"start_import": "Start Import",
"importing": "Importing...",
"import_complete": "Import Complete",
"import_failed": "Import Failed",
"import_history": "Import History",
"job_id": "Job ID",
"started_at": "Started At",
"completed_at": "Completed At",
"duration": "Duration",
"imported_count": "Imported",
"error_count": "Errors",
"total_processed": "Total Processed",
"progress": "Progress",
"no_import_jobs": "No import jobs yet",
"start_first_import": "Start your first import using the form above"
},
"letzshop": {
"title": "Letzshop Integration",
"connection": "Connection",
"credentials": "Credentials",
"api_key": "API Key",
"api_endpoint": "API Endpoint",
"auto_sync": "Auto Sync",
"sync_interval": "Sync Interval",
"every_hour": "Every hour",
"every_day": "Every day",
"test_connection": "Test Connection",
"save_credentials": "Save Credentials",
"connection_success": "Connection successful",
"connection_failed": "Connection failed",
"last_sync": "Last Sync",
"sync_status": "Sync Status",
"import_orders": "Import Orders",
"export_products": "Export Products",
"no_credentials": "Configure your API key in Settings to get started",
"carriers": {
"dhl": "DHL",
"ups": "UPS",
"fedex": "FedEx",
"dpd": "DPD",
"gls": "GLS",
"post_luxembourg": "Post Luxembourg",
"other": "Other"
}
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"please_configure_api_key_first": "Please configure your API key first",
"please_enter_api_key": "Please enter an API key",
"please_fill_in_all_fields": "Please fill in all fields"
},
"confirmations": {
"remove_letzshop_credentials": "Are you sure you want to remove your Letzshop credentials?",
"confirm_order": "Confirm this order?",
"reject_order": "Reject this order? This action cannot be undone.",
"remove_letzshop_config_store": "Are you sure you want to remove Letzshop configuration for this store?",
"decline_order": "Are you sure you want to decline this order? All items will be marked as unavailable.",
"confirm_all_items": "Are you sure you want to confirm all items in this order?",
"decline_all_items": "Are you sure you want to decline all items in this order?",
"remove_letzshop_config": "Are you sure you want to remove the Letzshop configuration? This will disable all Letzshop features for this store.",
"ignore_exception": "Are you sure you want to ignore this exception? The order will still be blocked from confirmation."
},
"features": {
"letzshop_sync": {
"name": "Lëtzshop Sync",
"description": "Synchronize products with Lëtzshop marketplace"
},
"api_access": {
"name": "API Access",
"description": "Access to the platform API"
},
"webhooks": {
"name": "Webhooks",
"description": "Real-time event notifications via webhooks"
},
"custom_integrations": {
"name": "Custom Integrations",
"description": "Build custom integrations with the platform"
}
},
"onboarding": {
"connect_api": {
"title": "Connect Letzshop API",
"description": "Configure your API key to sync with Letzshop"
},
"import_products": {
"title": "Import your products",
"description": "Import your product catalog from Letzshop"
}
}
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"please_configure_api_key_first": "Please configure your API key first",
"please_enter_api_key": "Please enter an API key",
"please_fill_in_all_fields": "Please fill in all fields"
},
"confirmations": {
"remove_letzshop_credentials": "Are you sure you want to remove your Letzshop credentials?",
"confirm_order": "Confirm this order?",
"reject_order": "Reject this order? This action cannot be undone.",
"remove_letzshop_config_store": "Are you sure you want to remove Letzshop configuration for this store?",
"decline_order": "Are you sure you want to decline this order? All items will be marked as unavailable.",
"confirm_all_items": "Are you sure you want to confirm all items in this order?",
"decline_all_items": "Are you sure you want to decline all items in this order?",
"remove_letzshop_config": "Are you sure you want to remove the Letzshop configuration? This will disable all Letzshop features for this store.",
"ignore_exception": "Are you sure you want to ignore this exception? The order will still be blocked from confirmation."
},
"features": {
"letzshop_sync": {
"name": "Lëtzshop Sync",
"description": "Synchronize products with Lëtzshop marketplace"
},
"api_access": {
"name": "API Access",
"description": "Access to the platform API"
},
"webhooks": {
"name": "Webhooks",
"description": "Real-time event notifications via webhooks"
},
"custom_integrations": {
"name": "Custom Integrations",
"description": "Build custom integrations with the platform"
}
}
}

View File

@@ -91,5 +91,15 @@
"manage_integration_desc": "Configurer l'intégration marketplace",
"sync_products": "Synchroniser les produits",
"sync_products_desc": "Synchroniser les produits avec le marketplace"
},
"onboarding": {
"connect_api": {
"title": "Connecter l'API Letzshop",
"description": "Configurez votre clé API pour synchroniser avec Letzshop"
},
"import_products": {
"title": "Importer vos produits",
"description": "Importez votre catalogue de produits depuis Letzshop"
}
}
}

View File

@@ -91,5 +91,15 @@
"manage_integration_desc": "Marché-Integratioun konfiguréieren",
"sync_products": "Produiten synchroniséieren",
"sync_products_desc": "Produiten mam Marché synchroniséieren"
},
"onboarding": {
"connect_api": {
"title": "Letzshop-API verbannen",
"description": "Konfiguréiert Ären API-Schlëssel fir mat Letzshop ze synchroniséieren"
},
"import_products": {
"title": "Produkter importéieren",
"description": "Importéiert Äre Produktkatalog vu Letzshop"
}
}
}

View File

@@ -27,8 +27,8 @@ class MarketplaceOnboardingProvider:
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,
@@ -36,8 +36,8 @@ class MarketplaceOnboardingProvider:
),
OnboardingStepDefinition(
key="marketplace.import_products",
title_key="onboarding.marketplace.import_products.title",
description_key="onboarding.marketplace.import_products.description",
title_key="marketplace.onboarding.import_products.title",
description_key="marketplace.onboarding.import_products.description",
icon="package",
route_template="/store/{store_code}/marketplace",
order=210,

View File

@@ -0,0 +1,35 @@
"""orders 002 - customer order stats table
Revision ID: orders_002
Revises: orders_001
Create Date: 2026-03-07
"""
import sqlalchemy as sa
from alembic import op
revision = "orders_002"
down_revision = "orders_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"customer_order_stats",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True),
sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id"), nullable=False, index=True),
sa.Column("total_orders", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total_spent_cents", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_order_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_order_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("store_id", "customer_id", name="uq_customer_order_stats_store_customer"),
)
op.create_index("idx_customer_order_stats_customer", "customer_order_stats", ["customer_id"])
def downgrade() -> None:
op.drop_table("customer_order_stats")

View File

@@ -5,6 +5,7 @@ Orders module database models.
This module contains the canonical implementations of order-related models.
"""
from app.modules.orders.models.customer_order_stats import CustomerOrderStats
from app.modules.orders.models.invoice import (
Invoice,
InvoiceStatus,
@@ -15,6 +16,7 @@ from app.modules.orders.models.order import Order, OrderItem
from app.modules.orders.models.order_item_exception import OrderItemException
__all__ = [
"CustomerOrderStats",
"Order",
"OrderItem",
"OrderItemException",

View File

@@ -0,0 +1,52 @@
# app/modules/orders/models/customer_order_stats.py
"""
Customer order statistics model.
Tracks per-customer order aggregates (total orders, total spent, etc.)
owned by the orders module. This separates order stats from the
customer profile data owned by the customers module.
"""
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class CustomerOrderStats(Base, TimestampMixin):
"""Aggregated order statistics per customer per store."""
__tablename__ = "customer_order_stats"
id = Column(Integer, primary_key=True, index=True)
store_id = Column(
Integer, ForeignKey("stores.id"), nullable=False, index=True
)
customer_id = Column(
Integer, ForeignKey("customers.id"), nullable=False, index=True
)
total_orders = Column(Integer, nullable=False, default=0)
total_spent_cents = Column(Integer, nullable=False, default=0)
last_order_date = Column(DateTime(timezone=True), nullable=True)
first_order_date = Column(DateTime(timezone=True), nullable=True)
# Relationships
store = relationship("Store")
customer = relationship("Customer")
__table_args__ = (
UniqueConstraint(
"store_id", "customer_id", name="uq_customer_order_stats_store_customer"
),
Index("idx_customer_order_stats_customer", "customer_id"),
)
def __repr__(self):
return (
f"<CustomerOrderStats("
f"store_id={self.store_id}, "
f"customer_id={self.customer_id}, "
f"total_orders={self.total_orders}, "
f"total_spent_cents={self.total_spent_cents})>"
)

View File

@@ -13,10 +13,11 @@ Similar to how:
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.modules.orders.models import Order
from app.modules.orders.models import CustomerOrderStats, Order
logger = logging.getLogger(__name__)
@@ -89,6 +90,51 @@ class CustomerOrderService:
.all()
)
def record_order(
self,
db: Session,
store_id: int,
customer_id: int,
total_amount_cents: int,
) -> CustomerOrderStats:
"""
Record an order in customer order stats (upsert).
Args:
db: Database session
store_id: Store ID
customer_id: Customer ID
total_amount_cents: Order total in cents
Returns:
Updated CustomerOrderStats
"""
stats = (
db.query(CustomerOrderStats)
.filter(
CustomerOrderStats.store_id == store_id,
CustomerOrderStats.customer_id == customer_id,
)
.first()
)
now = datetime.now(UTC)
if stats:
stats.total_orders = (stats.total_orders or 0) + 1
stats.total_spent_cents = (stats.total_spent_cents or 0) + total_amount_cents
stats.last_order_date = now
else:
stats = CustomerOrderStats(
store_id=store_id,
customer_id=customer_id,
total_orders=1,
total_spent_cents=total_amount_cents,
first_order_date=now,
last_order_date=now,
)
db.add(stats)
db.flush()
return stats
def get_order_count(
self,
db: Session,

View File

@@ -0,0 +1,84 @@
# tests/unit/models/test_customer_order_stats.py
"""Unit tests for CustomerOrderStats model."""
import pytest
from sqlalchemy.exc import IntegrityError
from app.modules.orders.models.customer_order_stats import CustomerOrderStats
@pytest.mark.unit
@pytest.mark.database
class TestCustomerOrderStatsModel:
"""Test CustomerOrderStats model."""
def test_create_stats(self, db, test_store, test_customer):
"""Test creating customer order stats."""
stats = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=3,
total_spent_cents=15000,
)
db.add(stats)
db.commit()
db.refresh(stats)
assert stats.id is not None
assert stats.store_id == test_store.id
assert stats.customer_id == test_customer.id
assert stats.total_orders == 3
assert stats.total_spent_cents == 15000
def test_default_values(self, db, test_store, test_customer):
"""Test default values for stats."""
stats = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
)
db.add(stats)
db.commit()
db.refresh(stats)
assert stats.total_orders == 0
assert stats.total_spent_cents == 0
assert stats.last_order_date is None
assert stats.first_order_date is None
def test_unique_constraint(self, db, test_store, test_customer):
"""Test unique constraint on store_id + customer_id."""
stats1 = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=1,
)
db.add(stats1)
db.commit()
stats2 = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=2,
)
db.add(stats2)
with pytest.raises(IntegrityError):
db.commit()
def test_update_stats(self, db, test_store, test_customer):
"""Test updating existing stats."""
stats = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=1,
total_spent_cents=5000,
)
db.add(stats)
db.commit()
stats.total_orders = 2
stats.total_spent_cents = 12000
db.commit()
db.refresh(stats)
assert stats.total_orders == 2
assert stats.total_spent_cents == 12000

View File

@@ -188,6 +188,13 @@ tenancy_module = ModuleDefinition(
route="/merchants/account/stores",
order=10,
),
MenuItemDefinition(
id="team",
label_key="tenancy.menu.team",
icon="user-group",
route="/merchants/account/team",
order=15,
),
MenuItemDefinition(
id="profile",
label_key="tenancy.menu.profile",
@@ -211,6 +218,7 @@ tenancy_module = ModuleDefinition(
icon="user-group",
route="/store/{store_code}/team",
order=5,
requires_permission="team.view",
),
MenuItemDefinition(
id="roles",

View File

@@ -144,5 +144,11 @@
"team_edit_desc": "Rollen und Berechtigungen der Mitglieder bearbeiten",
"team_remove": "Mitglieder entfernen",
"team_remove_desc": "Mitglieder aus dem Team entfernen"
},
"onboarding": {
"customize_store": {
"title": "Shop anpassen",
"description": "Fügen Sie eine Beschreibung und ein Logo hinzu"
}
}
}

View File

@@ -151,5 +151,11 @@
"name": "Audit Log",
"description": "Track all user actions and changes"
}
},
"onboarding": {
"customize_store": {
"title": "Customize your store",
"description": "Add a description and logo to your store"
}
}
}

View File

@@ -144,5 +144,11 @@
"team_edit_desc": "Modifier les rôles et permissions des membres",
"team_remove": "Supprimer des membres",
"team_remove_desc": "Retirer des membres de l'équipe"
},
"onboarding": {
"customize_store": {
"title": "Personnalisez votre boutique",
"description": "Ajoutez une description et un logo à votre boutique"
}
}
}

View File

@@ -144,5 +144,11 @@
"team_edit_desc": "Rollen a Rechter vun de Memberen änneren",
"team_remove": "Memberen ewechhuelen",
"team_remove_desc": "Memberen aus dem Team ewechhuelen"
},
"onboarding": {
"customize_store": {
"title": "Äre Buttek personaliséieren",
"description": "Setzt eng Beschreiwung an e Logo fir Äre Buttek derbäi"
}
}
}

View File

@@ -118,7 +118,7 @@ class Store(Base, TimestampMixin):
String(5), nullable=False, default="fr"
) # Default language for customer-facing storefront
storefront_languages = Column(
JSON, nullable=False, default=["fr", "de", "en"]
JSON, nullable=False, default=["fr", "de", "en", "lb"]
) # Array of enabled languages for storefront language selector
# Currency/number formatting locale (e.g., 'fr-LU' = "29,99 EUR", 'en-GB' = "EUR29.99")

View File

@@ -164,6 +164,7 @@ def get_accessible_platforms(
],
"is_super_admin": current_user.is_super_admin,
"requires_platform_selection": not current_user.is_super_admin and len(platforms) > 0,
"current_platform_id": current_user.token_platform_id,
}
@@ -175,10 +176,10 @@ def select_platform(
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
):
"""
Select platform context for platform admin.
Select platform context for an admin.
Issues a new JWT token with platform context.
Super admins skip this step (they have global access).
Available to both platform admins and super admins.
Args:
platform_id: Platform ID to select
@@ -186,13 +187,9 @@ def select_platform(
Returns:
PlatformSelectResponse with new token and platform info
"""
if current_user.is_super_admin:
raise InvalidCredentialsException(
"Super admins don't need platform selection - they have global access"
)
# Verify admin has access to this platform (raises exception if not)
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
# Platform admins must have access; super admins can access any platform
if not current_user.is_super_admin:
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
# Load platform
platform = admin_platform_service.get_platform_by_id(db, platform_id)
@@ -227,3 +224,45 @@ def select_platform(
platform_id=platform.id,
platform_code=platform.code,
)
@admin_auth_router.post("/deselect-platform")
def deselect_platform(
response: Response,
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
):
"""
Deselect platform context (return to global mode).
Only available to super admins. Issues a new JWT without platform context.
Returns:
New token without platform context
"""
if not current_user.is_super_admin:
raise InvalidCredentialsException(
"Only super admins can deselect platform (platform admins must always have a platform)"
)
# Issue new token without platform context
auth_manager = AuthManager()
token_data = auth_manager.create_access_token(user=current_user)
# Set cookie with new token
response.set_cookie(
key="admin_token",
value=token_data["access_token"],
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=token_data["expires_in"],
path="/admin",
)
logger.info(f"Super admin {current_user.username} deselected platform (global mode)")
return {
"access_token": token_data["access_token"],
"token_type": token_data["token_type"],
"expires_in": token_data["expires_in"],
}

View File

@@ -20,9 +20,13 @@ from app.modules.tenancy.schemas import (
MerchantPortalProfileResponse,
MerchantPortalProfileUpdate,
MerchantPortalStoreListResponse,
MerchantStoreCreate,
MerchantStoreDetailResponse,
MerchantStoreUpdate,
)
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.merchant_service import merchant_service
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
from .email_verification import email_verification_api_router
from .merchant_auth import merchant_auth_router
@@ -63,14 +67,113 @@ async def merchant_stores(
db, merchant.id, skip=skip, limit=limit
)
can_create, _ = merchant_store_service.can_create_store(db, merchant.id)
return MerchantPortalStoreListResponse(
stores=stores,
total=total,
skip=skip,
limit=limit,
can_create_store=can_create,
)
@_account_router.post("/stores", response_model=MerchantStoreDetailResponse)
async def create_merchant_store(
store_data: MerchantStoreCreate,
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Create a new store under the merchant.
Checks subscription tier store limits before creation.
New stores are created with is_active=True, is_verified=False.
"""
# Service raises MaxStoresReachedException, StoreAlreadyExistsException,
# or StoreValidationException — all handled by global exception handler.
result = merchant_store_service.create_store(
db,
merchant.id,
store_data.model_dump(),
)
db.commit()
logger.info(
f"Merchant {merchant.id} ({current_user.username}) created store "
f"'{store_data.store_code}'"
)
return result
@_account_router.get("/stores/{store_id}", response_model=MerchantStoreDetailResponse)
async def get_merchant_store_detail(
store_id: int,
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Get detailed store information with ownership validation.
Returns store details including platform assignments.
"""
# StoreNotFoundException handled by global exception handler
return merchant_store_service.get_store_detail(db, merchant.id, store_id)
@_account_router.put("/stores/{store_id}", response_model=MerchantStoreDetailResponse)
async def update_merchant_store(
store_id: int,
update_data: MerchantStoreUpdate,
current_user: UserContext = Depends(get_current_merchant_api),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Update store details (merchant-allowed fields only).
Only name, description, and contact info fields can be updated.
"""
# StoreNotFoundException handled by global exception handler
result = merchant_store_service.update_store(
db,
merchant.id,
store_id,
update_data.model_dump(exclude_unset=True),
)
db.commit()
logger.info(
f"Merchant {merchant.id} ({current_user.username}) updated store {store_id}"
)
return result
@_account_router.get("/platforms")
async def get_merchant_platforms(
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Get platforms available for the merchant (from active subscriptions).
Used by the store creation/edit UI to show platform selection options.
"""
return merchant_store_service.get_subscribed_platform_ids(db, merchant.id)
@_account_router.get("/team")
async def merchant_team_overview(
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Get team members across all stores owned by the merchant.
Returns a list of stores with their team members grouped by store.
"""
return merchant_store_service.get_merchant_team_overview(db, merchant.id)
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)
async def merchant_profile(
request: Request,

View File

@@ -50,6 +50,55 @@ async def merchant_stores_page(
)
@router.get("/stores/{store_id}", response_class=HTMLResponse, include_in_schema=False)
async def merchant_store_detail_page(
request: Request,
store_id: int,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render the merchant store detail/edit page.
Shows store details and allows editing merchant-allowed fields.
"""
context = get_context_for_frontend(
FrontendType.MERCHANT,
request,
db,
user=current_user,
)
context["store_id"] = store_id
return templates.TemplateResponse(
"tenancy/merchant/store-detail.html",
context,
)
@router.get("/team", response_class=HTMLResponse, include_in_schema=False)
async def merchant_team_page(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render the merchant team management page.
Shows team members across all stores owned by the merchant,
with ability to invite and manage per-store teams.
"""
context = get_context_for_frontend(
FrontendType.MERCHANT,
request,
db,
user=current_user,
)
return templates.TemplateResponse(
"tenancy/merchant/team.html",
context,
)
@router.get("/profile", response_class=HTMLResponse, include_in_schema=False)
async def merchant_profile_page(
request: Request,

View File

@@ -24,6 +24,7 @@ from app.api.deps import (
from app.modules.core.utils.page_context import get_store_context
from app.modules.tenancy.models import User
from app.templates_config import templates
from app.utils.i18n import get_jinja2_globals
router = APIRouter()
@@ -71,11 +72,13 @@ async def store_login_page(
url=f"/store/{store_code}/dashboard", status_code=302
)
language = getattr(request.state, "language", "fr")
return templates.TemplateResponse(
"tenancy/store/login.html",
{
"request": request,
"store_code": store_code,
**get_jinja2_globals(language),
},
)

View File

@@ -80,6 +80,9 @@ from app.modules.tenancy.schemas.merchant import (
MerchantPortalProfileUpdate,
MerchantPortalStoreListResponse,
MerchantResponse,
MerchantStoreCreate,
MerchantStoreDetailResponse,
MerchantStoreUpdate,
MerchantSummary,
MerchantTransferOwnership,
MerchantTransferOwnershipResponse,
@@ -163,6 +166,9 @@ __all__ = [
"MerchantPortalProfileUpdate",
"MerchantPortalStoreListResponse",
"MerchantResponse",
"MerchantStoreCreate",
"MerchantStoreDetailResponse",
"MerchantStoreUpdate",
"MerchantSummary",
"MerchantTransferOwnership",
"MerchantTransferOwnershipResponse",

View File

@@ -261,3 +261,73 @@ class MerchantPortalStoreListResponse(BaseModel):
total: int
skip: int
limit: int
can_create_store: bool = True
class MerchantStoreCreate(BaseModel):
"""Store creation from the merchant portal.
Subset of admin StoreCreate — excludes admin-only fields."""
name: str = Field(..., min_length=2, max_length=255, description="Store name")
store_code: str = Field(
..., min_length=2, max_length=50, description="Unique store code"
)
subdomain: str = Field(
..., min_length=2, max_length=100, description="Store subdomain"
)
description: str | None = Field(None, description="Store description")
platform_ids: list[int] = Field(
default_factory=list, description="Platform IDs to assign store to"
)
@field_validator("subdomain")
@classmethod
def validate_subdomain(cls, v):
"""Validate subdomain format."""
import re
v = v.lower().strip()
if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", v) and len(v) > 1:
raise ValueError(
"Subdomain must contain only lowercase letters, numbers, and hyphens"
)
return v
@field_validator("store_code")
@classmethod
def normalize_store_code(cls, v):
"""Normalize store code to uppercase."""
return v.upper().strip()
class MerchantStoreDetailResponse(BaseModel):
"""Store detail for the merchant portal."""
id: int
store_code: str
subdomain: str
name: str
description: str | None = None
is_active: bool
is_verified: bool
contact_email: str | None = None
contact_phone: str | None = None
website: str | None = None
business_address: str | None = None
tax_number: str | None = None
default_language: str | None = None
created_at: str | None = None
platforms: list[dict] = Field(default_factory=list)
class MerchantStoreUpdate(BaseModel):
"""Store update from the merchant portal.
Only merchant-allowed fields."""
name: str | None = Field(None, min_length=2, max_length=255)
description: str | None = None
contact_email: EmailStr | None = None
contact_phone: str | None = None
website: str | None = None
business_address: str | None = None
tax_number: str | None = None

View File

@@ -0,0 +1,435 @@
# app/modules/tenancy/services/merchant_store_service.py
"""
Merchant store service for store CRUD operations from the merchant portal.
Handles store management operations that merchant owners can perform:
- View store details (with ownership validation)
- Update store settings (name, description, contact info)
- Create new stores (with subscription limit checking)
Follows the service layer pattern — all DB operations go through here.
"""
import logging
from sqlalchemy import func
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import (
MerchantNotFoundException,
StoreAlreadyExistsException,
StoreNotFoundException,
StoreValidationException,
)
from app.modules.tenancy.models.merchant import Merchant
from app.modules.tenancy.models.platform import Platform
from app.modules.tenancy.models.store import Role, Store
from app.modules.tenancy.models.store_platform import StorePlatform
logger = logging.getLogger(__name__)
class MerchantStoreService:
"""Service for merchant-initiated store operations."""
def get_store_detail(
self,
db: Session,
merchant_id: int,
store_id: int,
) -> dict:
"""
Get store detail with ownership validation.
Args:
db: Database session
merchant_id: Merchant ID (for ownership check)
store_id: Store ID
Returns:
Dict with store details and platform assignments
Raises:
StoreNotFoundException: If store not found or not owned by merchant
"""
store = (
db.query(Store)
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
raise StoreNotFoundException(store_id, identifier_type="id")
# Get platform assignments
store_platforms = (
db.query(StorePlatform)
.join(Platform, StorePlatform.platform_id == Platform.id)
.filter(StorePlatform.store_id == store.id)
.all()
)
platforms = []
for sp in store_platforms:
platform = db.query(Platform).filter(Platform.id == sp.platform_id).first()
if platform:
platforms.append(
{
"id": platform.id,
"code": platform.code,
"name": platform.name,
"is_active": sp.is_active,
}
)
return {
"id": store.id,
"store_code": store.store_code,
"subdomain": store.subdomain,
"name": store.name,
"description": store.description,
"is_active": store.is_active,
"is_verified": store.is_verified,
"contact_email": store.contact_email,
"contact_phone": store.contact_phone,
"website": store.website,
"business_address": store.business_address,
"tax_number": store.tax_number,
"default_language": store.default_language,
"created_at": store.created_at.isoformat() if store.created_at else None,
"platforms": platforms,
}
def update_store(
self,
db: Session,
merchant_id: int,
store_id: int,
update_data: dict,
) -> dict:
"""
Update store fields (merchant-allowed fields only).
Args:
db: Database session
merchant_id: Merchant ID (for ownership check)
store_id: Store ID
update_data: Dict of fields to update
Returns:
Updated store detail dict
Raises:
StoreNotFoundException: If store not found or not owned by merchant
"""
store = (
db.query(Store)
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
.first()
)
if not store:
raise StoreNotFoundException(store_id, identifier_type="id")
# Merchant-allowed update fields
allowed_fields = {
"name",
"description",
"contact_email",
"contact_phone",
"website",
"business_address",
"tax_number",
}
for field, value in update_data.items():
if field in allowed_fields:
setattr(store, field, value)
db.flush()
logger.info(
f"Merchant {merchant_id} updated store {store.store_code}: "
f"{list(update_data.keys())}"
)
return self.get_store_detail(db, merchant_id, store_id)
def create_store(
self,
db: Session,
merchant_id: int,
store_data: dict,
) -> dict:
"""
Create a new store under the merchant.
Args:
db: Database session
merchant_id: Merchant ID
store_data: Store creation data (name, store_code, subdomain, description, platform_ids)
Returns:
Created store detail dict
Raises:
MaxStoresReachedException: If store limit reached
MerchantNotFoundException: If merchant not found
StoreAlreadyExistsException: If store code already exists
StoreValidationException: If subdomain taken or validation fails
"""
# Check store creation limits
can_create, message = self.can_create_store(db, merchant_id)
if not can_create:
from app.modules.tenancy.exceptions import MaxStoresReachedException
raise MaxStoresReachedException(max_stores=0)
# Validate merchant exists
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise MerchantNotFoundException(merchant_id, identifier_type="id")
store_code = store_data["store_code"].upper()
subdomain = store_data["subdomain"].lower()
# Check store code uniqueness
existing = (
db.query(Store)
.filter(func.upper(Store.store_code) == store_code)
.first()
)
if existing:
raise StoreAlreadyExistsException(store_code)
# Check subdomain uniqueness
existing_sub = (
db.query(Store)
.filter(func.lower(Store.subdomain) == subdomain)
.first()
)
if existing_sub:
raise StoreValidationException(
f"Subdomain '{subdomain}' is already taken",
field="subdomain",
)
try:
# Create store
store = Store(
merchant_id=merchant_id,
store_code=store_code,
subdomain=subdomain,
name=store_data["name"],
description=store_data.get("description"),
is_active=True,
is_verified=False, # Pending admin verification
)
db.add(store)
db.flush()
# Create default roles
self._create_default_roles(db, store.id)
# Assign to platforms if provided
platform_ids = store_data.get("platform_ids", [])
for pid in platform_ids:
platform = db.query(Platform).filter(Platform.id == pid).first()
if platform:
sp = StorePlatform(
store_id=store.id,
platform_id=pid,
is_active=True,
)
db.add(sp)
db.flush()
db.refresh(store)
logger.info(
f"Merchant {merchant_id} created store {store.store_code} "
f"(ID: {store.id}, platforms: {platform_ids})"
)
return self.get_store_detail(db, merchant_id, store.id)
except (
StoreAlreadyExistsException,
MerchantNotFoundException,
StoreValidationException,
):
raise
except SQLAlchemyError as e:
logger.error(f"Failed to create store for merchant {merchant_id}: {e}")
raise StoreValidationException(
f"Failed to create store: {e}",
field="store",
)
def can_create_store(
self,
db: Session,
merchant_id: int,
) -> tuple[bool, str | None]:
"""
Check if merchant can create a new store based on subscription limits.
Returns:
Tuple of (allowed, message). message explains why if not allowed.
"""
try:
from app.modules.billing.services.feature_service import feature_service
return feature_service.check_resource_limit(
db,
feature_code="stores_limit",
merchant_id=merchant_id,
)
except Exception:
# If billing module not available, allow creation
return True, None
def get_subscribed_platform_ids(
self,
db: Session,
merchant_id: int,
) -> list[dict]:
"""
Get platforms the merchant has active subscriptions on.
Returns:
List of platform dicts with id, code, name
"""
try:
from app.modules.billing.services.subscription_service import (
subscription_service,
)
platform_ids = subscription_service.get_active_subscription_platform_ids(
db, merchant_id
)
except Exception:
platform_ids = []
platforms = []
for pid in platform_ids:
platform = db.query(Platform).filter(Platform.id == pid).first()
if platform:
platforms.append(
{
"id": platform.id,
"code": platform.code,
"name": platform.name,
}
)
return platforms
def _create_default_roles(self, db: Session, store_id: int):
"""Create default roles for a new store."""
default_roles = [
{"name": "Owner", "permissions": ["*"]},
{
"name": "Manager",
"permissions": [
"products.*",
"orders.*",
"customers.view",
"inventory.*",
"team.view",
],
},
{
"name": "Editor",
"permissions": [
"products.view",
"products.edit",
"orders.view",
"inventory.view",
],
},
{
"name": "Viewer",
"permissions": [
"products.view",
"orders.view",
"customers.view",
"inventory.view",
],
},
]
roles = [
Role(
store_id=store_id,
name=role_data["name"],
permissions=role_data["permissions"],
)
for role_data in default_roles
]
db.add_all(roles)
def get_merchant_team_overview(self, db: Session, merchant_id: int) -> dict:
"""
Get team members across all stores owned by the merchant.
Returns a list of stores with their team members grouped by store.
"""
from app.modules.tenancy.models.store import StoreUser
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise MerchantNotFoundException(merchant_id)
stores = (
db.query(Store)
.filter(Store.merchant_id == merchant_id)
.order_by(Store.name)
.all()
)
result = []
for store in stores:
members = (
db.query(StoreUser)
.filter(StoreUser.store_id == store.id)
.all()
)
store_team = {
"store_id": store.id,
"store_name": store.name,
"store_code": store.store_code,
"is_active": store.is_active,
"members": [
{
"id": m.id,
"user_id": m.user_id,
"email": m.user.email if m.user else None,
"first_name": m.user.first_name if m.user else None,
"last_name": m.user.last_name if m.user else None,
"role_name": m.role.name if m.role else None,
"is_active": m.is_active,
"invitation_accepted_at": (
m.invitation_accepted_at.isoformat()
if m.invitation_accepted_at
else None
),
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in members
],
"member_count": len(members),
}
result.append(store_team)
return {
"merchant_name": merchant.business_name or merchant.brand_name,
"owner_email": merchant.owner.email if merchant.owner else None,
"stores": result,
"total_members": sum(s["member_count"] for s in result),
}
# Singleton instance
merchant_store_service = MerchantStoreService()
__all__ = ["MerchantStoreService", "merchant_store_service"]

View File

@@ -116,6 +116,8 @@ class PermissionDiscoveryService:
# Settings (limited)
"settings.view",
"settings.theme",
# Team (view only)
"team.view",
# Imports
"imports.view",
"imports.create",

View File

@@ -26,8 +26,8 @@ class TenancyOnboardingProvider:
return [
OnboardingStepDefinition(
key="tenancy.customize_store",
title_key="onboarding.tenancy.customize_store.title",
description_key="onboarding.tenancy.customize_store.description",
title_key="tenancy.onboarding.customize_store.title",
description_key="tenancy.onboarding.customize_store.description",
icon="settings",
route_template="/store/{store_code}/settings",
order=100,

View File

@@ -1,5 +1,5 @@
// static/admin/js/select-platform.js
// Platform selection page for platform admins
// Platform selection page for admins (platform admins and super admins)
const platformLog = window.LogConfig ? window.LogConfig.createLogger('PLATFORM_SELECT') : console;
@@ -11,6 +11,7 @@ function selectPlatform() {
error: null,
platforms: [],
isSuperAdmin: false,
currentPlatformId: null,
async init() {
platformLog.info('=== PLATFORM SELECTION PAGE INITIALIZING ===');
@@ -46,21 +47,14 @@ function selectPlatform() {
const response = await apiClient.get('/admin/auth/accessible-platforms');
platformLog.debug('Platforms response:', response);
this.isSuperAdmin = response.role === 'super_admin';
this.isSuperAdmin = response.is_super_admin === true;
this.platforms = response.platforms || [];
this.currentPlatformId = response.current_platform_id || null;
if (this.isSuperAdmin) {
platformLog.info('User is super admin, redirecting to dashboard...');
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 1500);
return;
}
if (!response.requires_platform_selection && this.platforms.length === 1) {
if (!this.isSuperAdmin && !response.requires_platform_selection && this.platforms.length === 1) {
// Only one platform assigned, auto-select it
platformLog.info('Single platform assigned, auto-selecting...');
await this.selectPlatform(this.platforms[0]);
await this.choosePlatform(this.platforms[0]);
return;
}
@@ -81,7 +75,11 @@ function selectPlatform() {
}
},
async selectPlatform(platform) {
isCurrentPlatform(platform) {
return this.currentPlatformId && this.currentPlatformId === platform.id;
},
async choosePlatform(platform) {
if (this.selecting) return;
this.selecting = true;
@@ -132,6 +130,37 @@ function selectPlatform() {
}
},
async deselectPlatform() {
if (this.selecting) return;
this.selecting = true;
this.error = null;
platformLog.info('Deselecting platform (returning to global mode)...');
try {
const response = await apiClient.post('/admin/auth/deselect-platform');
if (response.access_token) {
// Store new token without platform context
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('token', response.access_token);
// Remove platform info
localStorage.removeItem('admin_platform');
platformLog.info('Platform deselected, redirecting to dashboard...');
window.location.href = '/admin/dashboard';
} else {
throw new Error('No token received from server');
}
} catch (error) {
platformLog.error('Platform deselection failed:', error);
this.error = error.message || 'Failed to deselect platform. Please try again.';
this.selecting = false;
}
},
async logout() {
platformLog.info('Logging out...');

View File

@@ -8,6 +8,27 @@
// Create custom logger for store login page
const storeLoginLog = window.LogConfig.createLogger('STORE-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) {
storeLoginLog.error('Failed to set language:', error);
}
},
};
}
function storeLogin() {
return {
credentials: {
@@ -159,6 +180,7 @@ function storeLogin() {
},
// Forgot password state
rememberMe: false,
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,

View File

@@ -39,7 +39,6 @@ function storeSettings() {
{ id: 'invoices', label: 'Invoices', icon: 'document-text' },
{ id: 'branding', label: 'Branding', icon: 'color-swatch' },
{ id: 'domains', label: 'Domains', icon: 'globe-alt' },
{ id: 'api', label: 'API & Payments', icon: 'key' },
{ id: 'notifications', label: 'Notifications', icon: 'bell' },
{ id: 'email', label: 'Email', icon: 'mail' }
],

View File

@@ -32,7 +32,7 @@
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Admin Login
{{ _("auth.admin_login") }}
</h1>
<!-- Alert Messages -->
@@ -45,80 +45,129 @@
x-transition></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Username</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.username") }}</span>
<input x-model="credentials.username"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.username }"
placeholder="Enter your username"
placeholder="{{ _('auth.username_placeholder') }}"
autocomplete="username"
required />
<span x-show="errors.username" x-text="errors.username"
class="text-xs text-red-600 dark:text-red-400"></span>
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
<div class="relative" x-data="{ showPw: false }">
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
:type="showPw ? 'text' : 'password'"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.password }"
placeholder="{{ _('auth.password_placeholder') }}"
autocomplete="current-password"
required />
<button type="button"
@click="showPw = !showPw"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-text="showPw ? '👁️' : '👁️‍🗨️'"></span>
</button>
</div>
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
</label>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mt-4">
<label class="flex items-center text-sm">
<input type="checkbox"
x-model="rememberMe"
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
</label>
<a @click.prevent="showForgotPassword = true"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
{{ _("auth.forgot_password") }}
</a>
</div>
{# noqa: FE-002 - Inline spinner SVG for loading state #}
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center 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 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Sign in</span>
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
{{ _("auth.signing_in") }}
</span>
</button>
</form>
<!-- Forgot Password Form -->
<div x-show="showForgotPassword" x-cloak x-transition class="mt-6">
<hr class="mb-6" />
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
<form @submit.prevent="handleForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="forgotPasswordEmail"
:disabled="forgotPasswordLoading"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
placeholder="{{ _('auth.email_placeholder') }}"
type="email"
required />
</label>
<button type="submit" :disabled="forgotPasswordLoading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
</button>
</form>
<p class="mt-4">
<a @click.prevent="showForgotPassword = false"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
&larr; {{ _("auth.back_to_login") }}
</a>
</p>
</div>
<hr class="my-8" />
<p class="mt-4">
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
href="#">
Forgot your password?
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
href="/">
{{ _("auth.visit_platform") }}
</a>
</p>
<p class="mt-2">
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
Back to Platform
{{ _("auth.back_to_platform") }}
</a>
</p>
<!-- Language selector -->
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
async setLang(lang) {
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({language: lang})
});
window.location.reload();
}
}">
<button @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
<button @click="setLang('de')" class="fi fi-de text-lg opacity-60 hover:opacity-100 transition-opacity" title="Deutsch"></button>
<button @click="setLang('lb')" class="fi fi-lu text-lg opacity-60 hover:opacity-100 transition-opacity" title="Lëtzebuergesch"></button>
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("en") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>

View File

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

View File

@@ -37,20 +37,44 @@
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
</div>
<!-- Super Admin Notice -->
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-blue-700 dark:text-blue-400">
You are a Super Admin with access to all platforms. Redirecting to dashboard...
</p>
<!-- Global Mode Card (Super Admins Only) -->
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-3">
<button
@click="deselectPlatform()"
:disabled="selecting"
class="w-full flex items-center p-4 rounded-lg border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
:class="!currentPlatformId
? 'bg-green-50 dark:bg-green-900/20 border-green-500 ring-2 ring-green-200 dark:ring-green-800'
: 'bg-gray-50 dark:bg-gray-700 border-transparent hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20'"
>
<!-- Globe Icon -->
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center mr-4">
<span x-html="$icon('globe-alt', 'w-6 h-6 text-green-600 dark:text-green-400')"></span>
</div>
<!-- Info -->
<div class="flex-1 text-left">
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200">Global Mode (All Platforms)</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Access all modules across all platforms</p>
</div>
<!-- Checkmark for active -->
<div class="flex-shrink-0 ml-4" x-show="!currentPlatformId">
<span x-html="$icon('check-circle', 'w-6 h-6 text-green-600 dark:text-green-400')"></span>
</div>
</button>
</div>
<!-- Platform List -->
<div x-show="!loading && !isSuperAdmin && platforms.length > 0" x-cloak class="space-y-3">
<div x-show="!loading && platforms.length > 0" x-cloak class="space-y-3">
<template x-for="platform in platforms" :key="platform.id">
<button
@click="selectPlatform(platform)"
@click="choosePlatform(platform)"
:disabled="selecting"
class="w-full flex items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-transparent hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full flex items-center p-4 rounded-lg border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
:class="isCurrentPlatform(platform)
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-500 ring-2 ring-purple-200 dark:ring-purple-800'
: 'bg-gray-50 dark:bg-gray-700 border-transparent hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20'"
>
<!-- Platform Icon/Logo -->
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center mr-4">
@@ -68,9 +92,14 @@
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="platform.code"></p>
</div>
<!-- Arrow -->
<!-- Checkmark for active / Arrow for others -->
<div class="flex-shrink-0 ml-4">
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
<template x-if="isCurrentPlatform(platform)">
<span x-html="$icon('check-circle', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
</template>
<template x-if="!isCurrentPlatform(platform)">
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</template>
</div>
</button>
</template>
@@ -93,8 +122,41 @@
</button>
</div>
<!-- Theme Toggle -->
<div class="mt-4 flex justify-center">
<!-- Language & Theme Toggle -->
<div class="mt-4 flex justify-center items-center gap-2">
<!-- Language selector -->
<div class="relative" x-data="{ langOpen: false, currentLang: '{{ request.state.language|default('en') }}', async setLang(lang) { this.currentLang = lang; await fetch('/api/v1/platform/language/set', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({language: lang}) }); window.location.reload(); } }">
<button
@click="langOpen = !langOpen"
@click.outside="langOpen = false"
class="inline-flex items-center gap-1 p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
aria-label="Change language"
>
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
<span class="text-xs font-semibold uppercase" x-text="currentLang"></span>
</button>
<div
x-show="langOpen"
x-cloak
x-transition
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 w-40 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-100 dark:border-gray-600 py-1 z-50"
>
<button @click="setLang('en'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
<span class="font-semibold text-xs w-5">EN</span> English
</button>
<button @click="setLang('fr'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
<span class="font-semibold text-xs w-5">FR</span> Français
</button>
<button @click="setLang('de'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
<span class="font-semibold text-xs w-5">DE</span> Deutsch
</button>
<button @click="setLang('lb'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
<span class="font-semibold text-xs w-5">LB</span> Lëtzebuergesch
</button>
</div>
</div>
<!-- Dark mode toggle -->
<button
@click="toggleDarkMode()"
class="p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"

View File

@@ -33,7 +33,7 @@
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Merchant Login
{{ _("auth.merchant_login") }}
</h1>
<!-- Alert Messages -->
@@ -46,110 +46,129 @@
x-transition></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="credentials.email"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.email }"
placeholder="you@example.com"
placeholder="{{ _('auth.email_placeholder') }}"
autocomplete="username"
required />
<span x-show="errors.email" x-text="errors.email"
class="text-xs text-red-600 dark:text-red-400"></span>
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
<div class="relative" x-data="{ showPw: false }">
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
:type="showPw ? 'text' : 'password'"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.password }"
placeholder="{{ _('auth.password_placeholder') }}"
autocomplete="current-password"
required />
<button type="button"
@click="showPw = !showPw"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-text="showPw ? '👁️' : '👁️‍🗨️'"></span>
</button>
</div>
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
</label>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mt-4">
<label class="flex items-center text-sm">
<input type="checkbox"
x-model="rememberMe"
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
</label>
<a @click.prevent="showForgotPassword = true"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
{{ _("auth.forgot_password") }}
</a>
</div>
{# noqa: FE-002 - Inline spinner SVG for loading state #}
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center 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 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Sign in</span>
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
{{ _("auth.signing_in") }}
</span>
</button>
</form>
<hr class="my-8" />
<!-- Forgot Password Form -->
<div x-show="showForgotPassword" x-transition>
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
<div x-show="showForgotPassword" x-cloak x-transition class="mt-6">
<hr class="mb-6" />
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
<form @submit.prevent="handleForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="forgotPasswordEmail"
:disabled="forgotPasswordLoading"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
placeholder="you@example.com"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
placeholder="{{ _('auth.email_placeholder') }}"
type="email"
required />
</label>
<button type="submit" :disabled="forgotPasswordLoading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
<span x-show="forgotPasswordLoading">Sending...</span>
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
</button>
</form>
<p class="mt-4">
<a @click.prevent="showForgotPassword = false"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
&larr; Back to Login
&larr; {{ _("auth.back_to_login") }}
</a>
</p>
</div>
<div x-show="!showForgotPassword">
<p class="mt-4">
<a @click.prevent="showForgotPassword = true"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; Back to Platform
</a>
</p>
</div>
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
href="/">
{{ _("auth.visit_platform") }}
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; {{ _("auth.back_to_platform") }}
</a>
</p>
<!-- Language selector -->
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
async setLang(lang) {
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({language: lang})
});
window.location.reload();
}
}">
<button @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
<button @click="setLang('de')" class="fi fi-de text-lg opacity-60 hover:opacity-100 transition-opacity" title="Deutsch"></button>
<button @click="setLang('lb')" class="fi fi-lu text-lg opacity-60 hover:opacity-100 transition-opacity" title="Lëtzebuergesch"></button>
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>

View File

@@ -0,0 +1,315 @@
{# app/modules/tenancy/templates/tenancy/merchant/store-detail.html #}
{% extends "merchant/base.html" %}
{% block title %}Store Details{% endblock %}
{% block content %}
<div x-data="merchantStoreDetail({{ store_id }})">
<!-- Page Header -->
<div class="mb-8">
<a href="/merchants/account/stores" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Stores
</a>
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900" x-text="store?.name || 'Store Details'"></h2>
<p class="mt-1 text-sm text-gray-400 font-mono" x-text="store?.store_code"></p>
</div>
<div class="flex items-center gap-3">
<!-- Status Badges -->
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': store?.is_active,
'bg-gray-100 text-gray-600': !store?.is_active
}"
x-text="store?.is_active ? 'Active' : 'Inactive'"></span>
<template x-if="store?.is_verified">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">Verified</span>
</template>
<template x-if="store && !store.is_verified">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Pending Verification</span>
</template>
</div>
</div>
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p>
</div>
<!-- Success -->
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-sm text-green-800" x-text="successMessage"></p>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-500">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading store details...
</div>
<div x-show="!loading && store" class="space-y-6">
<!-- Store Information (read-only) -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Store Information</h3>
</div>
<div class="p-6">
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Store Code</dt>
<dd class="mt-1 text-sm text-gray-900 font-mono" x-text="store.store_code"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Subdomain</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.subdomain"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Default Language</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.default_language || 'Not set'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="formatDate(store.created_at)"></dd>
</div>
</dl>
</div>
</div>
<!-- Platform Assignments -->
<div x-show="store.platforms && store.platforms.length > 0" class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Platforms</h3>
</div>
<div class="p-6">
<div class="flex flex-wrap gap-2">
<template x-for="platform in store.platforms" :key="platform.id">
<span class="inline-flex items-center px-3 py-1.5 text-sm rounded-lg border"
:class="{
'bg-green-50 border-green-200 text-green-800': platform.is_active,
'bg-gray-50 border-gray-200 text-gray-600': !platform.is_active
}">
<span x-text="platform.name"></span>
<span class="ml-1.5 text-xs font-mono opacity-60" x-text="platform.code"></span>
</span>
</template>
</div>
</div>
</div>
<!-- Editable Fields -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Store Details</h3>
<template x-if="!editing">
<button @click="startEditing()" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Edit</button>
</template>
</div>
<!-- View Mode -->
<div x-show="!editing" class="p-6">
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">Name</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.name"></dd>
</div>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">Description</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.description || 'No description'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Contact Email</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.contact_email || '-'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Contact Phone</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.contact_phone || '-'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Website</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.website || '-'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Tax Number</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.tax_number || '-'"></dd>
</div>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">Business Address</dt>
<dd class="mt-1 text-sm text-gray-900" x-text="store.business_address || '-'"></dd>
</div>
</dl>
</div>
<!-- Edit Mode -->
<form x-show="editing" @submit.prevent="saveStore()" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" x-model="editForm.name" required minlength="2" maxlength="255"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea x-model="editForm.description" rows="2"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"></textarea>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
<input type="email" x-model="editForm.contact_email"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Phone</label>
<input type="tel" x-model="editForm.contact_phone"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Website</label>
<input type="url" x-model="editForm.website" placeholder="https://example.com"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Business Address</label>
<textarea x-model="editForm.business_address" rows="2"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tax Number (VAT ID)</label>
<input type="text" x-model="editForm.tax_number" placeholder="LU12345678"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button type="button" @click="editing = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Cancel
</button>
<button type="submit" :disabled="saving"
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
<span x-show="!saving">Save Changes</span>
<span x-show="saving" class="inline-flex items-center">
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Saving...
</span>
</button>
</div>
</form>
</div>
</div>
<!-- Not Found -->
<div x-show="!loading && !store && notFound" class="text-center py-16">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="text-lg font-semibold text-gray-700">Store not found</h3>
<p class="mt-1 text-gray-500">This store doesn't exist or you don't have access to it.</p>
<a href="/merchants/account/stores" class="mt-4 inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
&larr; Back to Stores
</a>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function merchantStoreDetail(storeId) {
return {
storeId,
loading: true,
store: null,
error: null,
successMessage: null,
notFound: false,
editing: false,
saving: false,
editForm: {},
init() {
this.loadStore();
},
async loadStore() {
try {
this.store = await apiClient.get(`/merchants/account/stores/${this.storeId}`);
} catch (err) {
if (err.status === 404) {
this.notFound = true;
} else {
this.error = 'Failed to load store details.';
}
} finally {
this.loading = false;
}
},
startEditing() {
this.editForm = {
name: this.store.name || '',
description: this.store.description || '',
contact_email: this.store.contact_email || '',
contact_phone: this.store.contact_phone || '',
website: this.store.website || '',
business_address: this.store.business_address || '',
tax_number: this.store.tax_number || '',
};
this.editing = true;
},
async saveStore() {
this.saving = true;
this.error = null;
this.successMessage = null;
// Only send fields that changed
const payload = {};
for (const [key, value] of Object.entries(this.editForm)) {
const original = this.store[key] || '';
if (value !== original) {
payload[key] = value || null;
}
}
if (Object.keys(payload).length === 0) {
this.editing = false;
this.saving = false;
return;
}
try {
this.store = await apiClient.put(
`/merchants/account/stores/${this.storeId}`,
payload
);
this.editing = false;
this.successMessage = 'Store updated successfully.';
setTimeout(() => { this.successMessage = null; }, 3000);
} catch (err) {
this.error = err.message || 'Failed to update store.';
} finally {
this.saving = false;
}
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
};
}
</script>
{% endblock %}

View File

@@ -7,9 +7,21 @@
<div x-data="merchantStores()">
<!-- Page Header -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900">My Stores</h2>
<p class="mt-1 text-gray-500">View and manage your connected stores.</p>
<div class="mb-8 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900">My Stores</h2>
<p class="mt-1 text-gray-500">View and manage your connected stores.</p>
</div>
<button
x-show="canCreateStore"
@click="showCreateModal = true"
class="inline-flex items-center px-4 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Store
</button>
</div>
<!-- Error -->
@@ -17,6 +29,11 @@
<p class="text-sm text-red-800" x-text="error"></p>
</div>
<!-- Success -->
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-sm text-green-800" x-text="successMessage"></p>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-500">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
@@ -33,50 +50,195 @@
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
<h3 class="text-lg font-semibold text-gray-700">No stores yet</h3>
<p class="mt-1 text-gray-500">Your stores will appear here once connected.</p>
<p class="mt-1 text-gray-500">Create your first store to get started.</p>
<button
x-show="canCreateStore"
@click="showCreateModal = true"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-semibold text-indigo-600 border border-indigo-300 rounded-lg hover:bg-indigo-50 transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Store
</button>
</div>
<!-- Store Cards Grid -->
<div x-show="!loading && stores.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<template x-for="store in stores" :key="store.id">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
<a :href="'/merchants/account/stores/' + store.id"
class="block bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md hover:border-indigo-200 transition-all cursor-pointer group">
<div class="p-6">
<!-- Store Name and Status -->
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900" x-text="store.name"></h3>
<h3 class="text-lg font-semibold text-gray-900 group-hover:text-indigo-600 transition-colors" x-text="store.name"></h3>
<p class="text-sm text-gray-400 font-mono" x-text="store.store_code"></p>
</div>
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': store.status === 'active',
'bg-yellow-100 text-yellow-800': store.status === 'pending',
'bg-gray-100 text-gray-600': store.status === 'inactive',
'bg-red-100 text-red-800': store.status === 'suspended'
'bg-green-100 text-green-800': store.is_active,
'bg-gray-100 text-gray-600': !store.is_active
}"
x-text="(store.status || 'active').toUpperCase()"></span>
x-text="store.is_active ? 'ACTIVE' : 'INACTIVE'"></span>
</div>
<!-- Details -->
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500">Store Code</dt>
<dd class="font-medium text-gray-900" x-text="store.store_code"></dd>
<dt class="text-gray-500">Subdomain</dt>
<dd class="font-medium text-gray-900" x-text="store.subdomain || '-'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Created</dt>
<dd class="font-medium text-gray-900" x-text="formatDate(store.created_at)"></dd>
</div>
<div x-show="store.platform_name" class="flex justify-between">
<dt class="text-gray-500">Platform</dt>
<dd class="font-medium text-gray-900" x-text="store.platform_name"></dd>
</div>
</dl>
<!-- Verification Badge -->
<div class="mt-4 flex items-center gap-2">
<template x-if="store.is_verified">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
Verified
</span>
</template>
<template x-if="!store.is_verified">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-yellow-100 text-yellow-800">
Pending Verification
</span>
</template>
</div>
</div>
</div>
<!-- Card Footer -->
<div class="px-6 py-3 bg-gray-50 border-t border-gray-100 text-right">
<span class="text-xs text-indigo-600 font-medium group-hover:underline">View Details &rarr;</span>
</div>
</a>
</template>
</div>
<!-- Create Store Modal -->
<div x-show="showCreateModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center">
<div class="fixed inset-0 bg-black bg-opacity-50" @click="showCreateModal = false"></div>
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-4" @click.stop>
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Create New Store</h3>
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form @submit.prevent="createStore()" class="p-6 space-y-4">
<!-- Store Name -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Store Name</label>
<input
type="text"
x-model="createForm.name"
@input="autoGenerateCode()"
required
minlength="2"
maxlength="255"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="My Awesome Store"
/>
</div>
<!-- Store Code & Subdomain -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Store Code</label>
<input
type="text"
x-model="createForm.store_code"
required
minlength="2"
maxlength="50"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 font-mono uppercase focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="MYSTORE"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Subdomain</label>
<input
type="text"
x-model="createForm.subdomain"
required
minlength="2"
maxlength="100"
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 lowercase focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="my-store"
/>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
x-model="createForm.description"
rows="2"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="Brief description of the store"
></textarea>
</div>
<!-- Platform Selection -->
<div x-show="availablePlatforms.length > 0">
<label class="block text-sm font-medium text-gray-700 mb-2">Platforms</label>
<div class="space-y-2">
<template x-for="platform in availablePlatforms" :key="platform.id">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:value="platform.id"
x-model.number="createForm.platform_ids"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span class="text-sm text-gray-700" x-text="platform.name"></span>
<span class="text-xs text-gray-400 font-mono" x-text="platform.code"></span>
</label>
</template>
</div>
</div>
<!-- Create Error -->
<div x-show="createError" class="p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="createError"></p>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
@click="showCreateModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>Cancel</button>
<button
type="submit"
:disabled="creating"
class="inline-flex items-center px-5 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
<span x-show="!creating">Create Store</span>
<span x-show="creating" class="inline-flex items-center">
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Creating...
</span>
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -86,7 +248,20 @@ function merchantStores() {
return {
loading: true,
error: null,
successMessage: null,
stores: [],
canCreateStore: false,
showCreateModal: false,
creating: false,
createError: null,
availablePlatforms: [],
createForm: {
name: '',
store_code: '',
subdomain: '',
description: '',
platform_ids: [],
},
init() {
this.loadStores();
@@ -96,6 +271,7 @@ function merchantStores() {
try {
const data = await apiClient.get('/merchants/account/stores');
this.stores = data.stores || data.items || [];
this.canCreateStore = data.can_create_store !== false;
} catch (err) {
console.error('Error loading stores:', err);
this.error = 'Failed to load stores. Please try again.';
@@ -104,6 +280,45 @@ function merchantStores() {
}
},
autoGenerateCode() {
const name = this.createForm.name;
this.createForm.store_code = name
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 20);
this.createForm.subdomain = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50);
},
async createStore() {
this.creating = true;
this.createError = null;
// Load platforms if not yet loaded
if (this.availablePlatforms.length === 0) {
try {
this.availablePlatforms = await apiClient.get('/merchants/account/platforms');
} catch (e) { /* ignore */ }
}
try {
await apiClient.post('/merchants/account/stores', this.createForm);
this.showCreateModal = false;
this.createForm = { name: '', store_code: '', subdomain: '', description: '', platform_ids: [] };
this.successMessage = 'Store created successfully! It is pending admin verification.';
setTimeout(() => { this.successMessage = null; }, 5000);
await this.loadStores();
} catch (err) {
this.createError = err.message || 'Failed to create store.';
} finally {
this.creating = false;
}
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });

View File

@@ -0,0 +1,140 @@
{# app/modules/tenancy/templates/tenancy/merchant/team.html #}
{% extends "merchant/base.html" %}
{% block title %}{{ _("tenancy.team.title") }}{% endblock %}
{% block content %}
<div x-data="merchantTeam()">
<!-- Page Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("tenancy.team.title") }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ _("tenancy.team.members") }}
<span x-show="data" class="font-medium" x-text="`(${data?.total_members || 0})`"></span>
</p>
</div>
</div>
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ _("common.loading") }}
</div>
<!-- Store Teams -->
<div x-show="!loading && data" x-cloak class="space-y-6">
<template x-for="store in data?.stores || []" :key="store.store_id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Store Header -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<span x-html="$icon('shopping-bag', 'w-5 h-5 text-gray-400')"></span>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="store.store_name"></h3>
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
</div>
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="store.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="store.is_active ? '{{ _("common.active") }}' : '{{ _("common.inactive") }}'">
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="`${store.member_count} {{ _("tenancy.team.members").toLowerCase() }}`"></span>
<a :href="`/store/${store.store_code}/team`"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors">
<span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
{{ _("common.view") }}
</a>
</div>
</div>
<!-- Members List -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Owner Row -->
<div class="px-6 py-3 flex items-center gap-4 bg-gray-50/50 dark:bg-gray-700/30">
<div class="w-8 h-8 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center flex-shrink-0">
<span x-html="$icon('shield-check', 'w-4 h-4 text-indigo-600 dark:text-indigo-400')"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="data?.owner_email || '{{ _("tenancy.team.owner") }}'"></p>
</div>
<span class="px-2 py-0.5 text-xs font-medium bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 rounded-full">{{ _("tenancy.team.owner") }}</span>
</div>
<!-- Team Members -->
<template x-for="member in store.members" :key="member.id">
<div class="px-6 py-3 flex items-center gap-4">
<div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
<span x-html="$icon('user', 'w-4 h-4 text-gray-500 dark:text-gray-400')"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">
<span x-text="member.first_name || ''"></span>
<span x-text="member.last_name || ''"></span>
<span x-show="!member.first_name && !member.last_name" x-text="member.email"></span>
</p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"
x-show="member.first_name || member.last_name"></p>
</div>
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="member.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'"
x-text="member.is_active ? (member.role_name || '{{ _("tenancy.team.members") }}') : '{{ _("common.pending") }}'">
</span>
</div>
</template>
<!-- Empty State -->
<template x-if="store.members.length === 0">
<div class="px-6 py-6 text-center text-sm text-gray-400 dark:text-gray-500">
{{ _("tenancy.team.title") }} - {{ _("common.none") }}
</div>
</template>
</div>
</div>
</template>
<!-- Empty State: No Stores -->
<template x-if="data && data.stores.length === 0">
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<span x-html="$icon('user-group', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ _("common.not_available") }}</p>
</div>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function merchantTeam() {
return {
loading: true,
error: null,
data: null,
async init() {
try {
this.data = await apiClient.get('/merchants/tenancy/account/team');
} catch (e) {
this.error = e.message || 'Failed to load team data';
} finally {
this.loading = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -40,7 +40,7 @@
</template>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Store Portal Login
{{ _("auth.store_login") }}
</h1>
<!-- Alert Messages -->
@@ -54,42 +54,63 @@
<!-- Login Form (only show if store found) -->
<template x-if="store">
<form @submit.prevent="handleLogin">
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Username</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.username") }}</span>
<input x-model="credentials.username"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.username }"
placeholder="Enter your username"
placeholder="{{ _('auth.username_placeholder') }}"
autocomplete="username"
required />
<span x-show="errors.username" x-text="errors.username"
class="text-xs text-red-600 dark:text-red-400"></span>
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
<div class="relative" x-data="{ showPw: false }">
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
:type="showPw ? 'text' : 'password'"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.password }"
placeholder="{{ _('auth.password_placeholder') }}"
autocomplete="current-password"
required />
<button type="button"
@click="showPw = !showPw"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-text="showPw ? '👁️' : '👁️‍🗨️'"></span>
</button>
</div>
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
</label>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mt-4">
<label class="flex items-center text-sm">
<input type="checkbox"
x-model="rememberMe"
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
</label>
<a @click.prevent="showForgotPassword = true"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
{{ _("auth.forgot_password") }}
</a>
</div>
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center 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 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Sign in</span>
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Signing in...
{{ _("auth.signing_in") }}
</span>
</button>
</form>
@@ -117,49 +138,64 @@
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading store information...</p>
</div>
<hr class="my-8" />
<!-- Forgot Password Form -->
<div x-show="showForgotPassword" x-transition>
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
<div x-show="store && showForgotPassword" x-cloak x-transition class="mt-6">
<hr class="mb-6" />
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
<form @submit.prevent="handleForgotPassword">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="forgotPasswordEmail"
:disabled="forgotPasswordLoading"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
placeholder="you@example.com"
type="email"
required />
</label>
<button type="submit" :disabled="forgotPasswordLoading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
<span x-show="forgotPasswordLoading">Sending...</span>
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
</button>
</form>
<p class="mt-4">
<a @click.prevent="showForgotPassword = false"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
&larr; Back to Login
&larr; {{ _("auth.back_to_login") }}
</a>
</p>
</div>
<div x-show="!showForgotPassword">
<p class="mt-4">
<a @click.prevent="showForgotPassword = true"
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; Back to Platform
</a>
</p>
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
href="/">
{{ _("auth.visit_platform") }}
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; {{ _("auth.back_to_platform") }}
</a>
</p>
<!-- Language selector -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>

View File

@@ -29,7 +29,6 @@
{{ 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 %}
@@ -1274,79 +1273,6 @@
</div>
</div>
<!-- API & Payments Settings -->
<div x-show="activeSection === 'api'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">API & Payments</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Payment integrations and API access</p>
</div>
<div class="p-4">
<div class="space-y-6">
<!-- Stripe Integration -->
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('credit-card', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
</div>
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-300">Stripe</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">Payment processing</p>
</div>
</div>
<template x-if="settings?.stripe_info?.has_stripe_customer">
<div class="space-y-3">
<div class="flex items-center gap-2">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
<span class="text-sm text-green-700 dark:text-green-300">Connected</span>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Customer ID</label>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="settings?.stripe_info?.customer_id_masked"></p>
</div>
</div>
</template>
<template x-if="!settings?.stripe_info?.has_stripe_customer">
<div class="flex items-center gap-2">
<span x-html="$icon('x-circle', 'w-5 h-5 text-gray-400 dark:text-gray-500')"></span>
<span class="text-sm text-gray-500 dark:text-gray-400">Not connected</span>
</div>
</template>
</div>
<!-- Letzshop API (if credentials exist) -->
<template x-if="settings?.letzshop?.has_credentials">
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-orange-100 dark:bg-orange-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('shopping-cart', 'w-6 h-6 text-orange-600 dark:text-orange-400')"></span>
</div>
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-300">Letzshop API</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">Marketplace integration</p>
</div>
</div>
<div class="flex items-center gap-2">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
<span class="text-sm text-green-700 dark:text-green-300">Credentials configured</span>
</div>
</div>
</template>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-2">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
API keys and payment credentials are managed securely. Contact support for changes.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Notification Settings -->
<div x-show="activeSection === 'notifications'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-4 border-b dark:border-gray-700">

View File

@@ -0,0 +1,255 @@
# tests/unit/test_merchant_store_service.py
"""Unit tests for MerchantStoreService."""
import uuid
import pytest
from app.modules.tenancy.exceptions import (
MerchantNotFoundException,
StoreAlreadyExistsException,
StoreNotFoundException,
StoreValidationException,
)
from app.modules.tenancy.models import Merchant, Store
from app.modules.tenancy.services.merchant_store_service import MerchantStoreService
@pytest.fixture
def merchant_owner(db, test_user):
"""Create a merchant owned by test_user."""
unique_id = str(uuid.uuid4())[:8]
merchant = Merchant(
name=f"Test Merchant {unique_id}",
owner_user_id=test_user.id,
contact_email=f"merchant{unique_id}@test.com",
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def other_merchant_owner(db):
"""Create a separate merchant for ownership isolation tests."""
from app.modules.tenancy.models.user import User
unique_id = str(uuid.uuid4())[:8]
user = User(
email=f"other{unique_id}@test.com",
username=f"other_{unique_id}",
hashed_password="fakehash", # noqa: SEC001
role="merchant_owner",
is_active=True,
)
db.add(user)
db.flush()
merchant = Merchant(
name=f"Other Merchant {unique_id}",
owner_user_id=user.id,
contact_email=f"otherm{unique_id}@test.com",
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def merchant_store(db, merchant_owner):
"""Create a store under the merchant."""
unique_id = str(uuid.uuid4())[:8]
store = Store(
merchant_id=merchant_owner.id,
store_code=f"MS_{unique_id}".upper(),
subdomain=f"ms-{unique_id}".lower(),
name=f"Merchant Store {unique_id}",
description="A test store",
is_active=True,
is_verified=False,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.mark.unit
@pytest.mark.tenancy
class TestMerchantStoreServiceGetDetail:
"""Tests for get_store_detail."""
def setup_method(self):
self.service = MerchantStoreService()
def test_get_store_detail_success(self, db, merchant_owner, merchant_store):
"""Test getting store detail with valid ownership."""
result = self.service.get_store_detail(db, merchant_owner.id, merchant_store.id)
assert result["id"] == merchant_store.id
assert result["store_code"] == merchant_store.store_code
assert result["name"] == merchant_store.name
assert result["is_active"] is True
assert "platforms" in result
def test_get_store_detail_wrong_merchant(
self, db, other_merchant_owner, merchant_store
):
"""Test that accessing another merchant's store raises not found."""
with pytest.raises(StoreNotFoundException):
self.service.get_store_detail(
db, other_merchant_owner.id, merchant_store.id
)
def test_get_store_detail_nonexistent(self, db, merchant_owner):
"""Test getting a non-existent store."""
with pytest.raises(StoreNotFoundException):
self.service.get_store_detail(db, merchant_owner.id, 99999)
@pytest.mark.unit
@pytest.mark.tenancy
class TestMerchantStoreServiceUpdate:
"""Tests for update_store."""
def setup_method(self):
self.service = MerchantStoreService()
def test_update_store_success(self, db, merchant_owner, merchant_store):
"""Test updating store fields."""
result = self.service.update_store(
db,
merchant_owner.id,
merchant_store.id,
{"name": "Updated Name", "contact_email": "new@test.com"},
)
db.commit()
assert result["name"] == "Updated Name"
assert result["contact_email"] == "new@test.com"
def test_update_store_ignores_disallowed_fields(
self, db, merchant_owner, merchant_store
):
"""Test that admin-only fields are ignored."""
result = self.service.update_store(
db,
merchant_owner.id,
merchant_store.id,
{"is_active": False, "is_verified": True, "name": "OK Name"},
)
db.commit()
# is_active and is_verified should NOT change
assert result["is_active"] is True
assert result["is_verified"] is False
assert result["name"] == "OK Name"
def test_update_store_wrong_merchant(
self, db, other_merchant_owner, merchant_store
):
"""Test that updating another merchant's store raises not found."""
with pytest.raises(StoreNotFoundException):
self.service.update_store(
db,
other_merchant_owner.id,
merchant_store.id,
{"name": "Hacked"},
)
@pytest.mark.unit
@pytest.mark.tenancy
class TestMerchantStoreServiceCreate:
"""Tests for create_store."""
def setup_method(self):
self.service = MerchantStoreService()
def test_create_store_success(self, db, merchant_owner):
"""Test successful store creation."""
unique_id = str(uuid.uuid4())[:8]
store_data = {
"name": f"New Store {unique_id}",
"store_code": f"NEW_{unique_id}",
"subdomain": f"new-{unique_id}".lower(),
"description": "Test description",
"platform_ids": [],
}
result = self.service.create_store(db, merchant_owner.id, store_data)
db.commit()
assert result["name"] == store_data["name"]
assert result["store_code"] == store_data["store_code"].upper()
assert result["is_active"] is True
assert result["is_verified"] is False
def test_create_store_duplicate_code(self, db, merchant_owner, merchant_store):
"""Test creating store with duplicate store code."""
store_data = {
"name": "Another Store",
"store_code": merchant_store.store_code,
"subdomain": f"another-{uuid.uuid4().hex[:8]}",
"description": None,
"platform_ids": [],
}
with pytest.raises(StoreAlreadyExistsException):
self.service.create_store(db, merchant_owner.id, store_data)
def test_create_store_duplicate_subdomain(self, db, merchant_owner, merchant_store):
"""Test creating store with duplicate subdomain."""
store_data = {
"name": "Another Store",
"store_code": f"UNIQUE_{uuid.uuid4().hex[:8]}",
"subdomain": merchant_store.subdomain,
"description": None,
"platform_ids": [],
}
with pytest.raises(StoreValidationException):
self.service.create_store(db, merchant_owner.id, store_data)
def test_create_store_nonexistent_merchant(self, db):
"""Test creating store for non-existent merchant."""
store_data = {
"name": "No Merchant Store",
"store_code": f"NM_{uuid.uuid4().hex[:8]}",
"subdomain": f"nm-{uuid.uuid4().hex[:8]}",
"description": None,
"platform_ids": [],
}
with pytest.raises(MerchantNotFoundException):
self.service.create_store(db, 99999, store_data)
def test_create_store_creates_default_roles(self, db, merchant_owner):
"""Test that default roles are created for new store."""
from app.modules.tenancy.models.store import Role
unique_id = str(uuid.uuid4())[:8]
store_data = {
"name": f"Roles Store {unique_id}",
"store_code": f"ROLE_{unique_id}",
"subdomain": f"role-{unique_id}".lower(),
"description": None,
"platform_ids": [],
}
result = self.service.create_store(db, merchant_owner.id, store_data)
db.commit()
roles = db.query(Role).filter(Role.store_id == result["id"]).all()
role_names = {r.name for r in roles}
assert "Owner" in role_names
assert "Manager" in role_names
assert "Editor" in role_names
assert "Viewer" in role_names

View File

@@ -20,10 +20,14 @@
</div>
<ul class="flex items-center flex-shrink-0 space-x-6">
<!-- Platform Context Indicator (for platform admins) -->
<!-- Platform Context Indicator -->
<li class="flex" x-data="platformContext()">
<template x-if="platform && !isSuperAdmin">
<!-- Super Admin + Platform selected -->
<template x-if="isSuperAdmin && platform">
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-xs font-medium text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-900/30 rounded-full">
Super Admin
</span>
<span class="px-3 py-1 text-xs font-medium text-purple-700 bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 rounded-full">
<span x-text="platform.name"></span>
</span>
@@ -34,10 +38,31 @@
</a>
</div>
</template>
<template x-if="isSuperAdmin">
<span class="px-3 py-1 text-xs font-medium text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-900/30 rounded-full">
Super Admin
</span>
<!-- Super Admin + Global mode (no platform) -->
<template x-if="isSuperAdmin && !platform">
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-xs font-medium text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-900/30 rounded-full">
Super Admin (Global)
</span>
<a href="/admin/select-platform"
class="text-xs text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="Switch platform">
<span x-html="$icon('switch-horizontal', 'w-4 h-4')"></span>
</a>
</div>
</template>
<!-- Platform Admin -->
<template x-if="!isSuperAdmin && platform">
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-xs font-medium text-purple-700 bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 rounded-full">
<span x-text="platform.name"></span>
</span>
<a href="/admin/select-platform"
class="text-xs text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="Switch platform">
<span x-html="$icon('switch-horizontal', 'w-4 h-4')"></span>
</a>
</div>
</template>
</li>
@@ -55,10 +80,11 @@
<button
@click="isLangOpen = !isLangOpen"
@click.outside="isLangOpen = false"
class="p-1 rounded-md focus:outline-none focus:shadow-outline-purple"
class="inline-flex items-center gap-1 p-1 rounded-md focus:outline-none focus:shadow-outline-purple"
aria-label="Change language"
>
<span class="fi text-lg" :class="'fi-' + languageFlags[currentLang]"></span>
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
<span class="text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase" x-text="currentLang"></span>
</button>
<div
x-show="isLangOpen"
@@ -263,7 +289,7 @@ function platformContext() {
if (storedUser) {
try {
const user = JSON.parse(storedUser);
this.isSuperAdmin = user.role === 'super_admin';
this.isSuperAdmin = user.is_super_admin === true || user.role === 'super_admin';
} catch (e) {
console.warn('Failed to parse stored user:', e);
}

View File

@@ -31,10 +31,11 @@
<button
@click="isLangOpen = !isLangOpen"
@click.outside="isLangOpen = false"
class="p-1 rounded-md focus:outline-none focus:shadow-outline-purple"
class="inline-flex items-center gap-1 p-1 rounded-md focus:outline-none focus:shadow-outline-purple"
aria-label="Change language"
>
<span class="fi text-lg" :class="'fi-' + languageFlags[currentLang]"></span>
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
<span class="text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase" x-text="currentLang"></span>
</button>
<div
x-show="isLangOpen"

View File

@@ -52,7 +52,13 @@
@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 class="flex items-center">
<span x-show="section.icon" x-html="$icon(section.icon, 'w-4 h-4 mr-2')" class="opacity-60"></span>
<span x-text="section.label"></span>
<span x-show="section.platform_code"
class="ml-2 px-1.5 py-0.5 text-[10px] font-medium rounded bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 normal-case"
x-text="section.platform_code?.toUpperCase()"></span>
</span>
<span
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
:class="{ 'rotate-180': openSections[section.id] }"

View File

@@ -86,10 +86,11 @@
@click="isOpen = !isOpen"
@click.outside="isOpen = false"
type="button"
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
class="inline-flex items-center gap-1 p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
:class="{ 'bg-gray-100 dark:bg-gray-700': isOpen }"
>
<span class="fi text-lg" :class="'fi-' + flags[currentLang]"></span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>
<span class="text-xs font-semibold uppercase" x-text="currentLang"></span>
</button>
<div
x-show="isOpen"

Some files were not shown because too many files have changed in this diff Show More