feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
All checks were successful
- 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:
@@ -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
|
||||
|
||||
@@ -96,6 +96,7 @@ analytics_module = ModuleDefinition(
|
||||
icon="chart-bar",
|
||||
route="/store/{store_code}/analytics",
|
||||
order=20,
|
||||
requires_permission="analytics.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platform_settings": "Plattform-Einstellungen",
|
||||
"general": "Allgemein",
|
||||
"my_menu": "Mein Menü",
|
||||
"account_settings": "Kontoeinstellungen",
|
||||
"profile": "Profil",
|
||||
"settings": "Einstellungen"
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platform_settings": "Platform Settings",
|
||||
"general": "General",
|
||||
"my_menu": "My Menu",
|
||||
"account_settings": "Account Settings",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
150
app/modules/customers/static/store/js/customer-detail.js
Normal file
150
app/modules/customers/static/store/js/customer-detail.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
← {{ _("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
|
||||
|
||||
@@ -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
|
||||
← {{ _("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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ===');
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
52
app/modules/orders/models/customer_order_stats.py
Normal file
52
app/modules/orders/models/customer_order_stats.py
Normal 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})>"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
84
app/modules/orders/tests/unit/test_customer_order_stats.py
Normal file
84
app/modules/orders/tests/unit/test_customer_order_stats.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
435
app/modules/tenancy/services/merchant_store_service.py
Normal file
435
app/modules/tenancy/services/merchant_store_service.py
Normal 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"]
|
||||
@@ -116,6 +116,8 @@ class PermissionDiscoveryService:
|
||||
# Settings (limited)
|
||||
"settings.view",
|
||||
"settings.theme",
|
||||
# Team (view only)
|
||||
"team.view",
|
||||
# Imports
|
||||
"imports.view",
|
||||
"imports.create",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
],
|
||||
|
||||
@@ -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">
|
||||
← {{ _("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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
← Back to Login
|
||||
← {{ _("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="/">
|
||||
← 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="/">
|
||||
← {{ _("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>
|
||||
|
||||
315
app/modules/tenancy/templates/tenancy/merchant/store-detail.html
Normal file
315
app/modules/tenancy/templates/tenancy/merchant/store-detail.html
Normal 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">
|
||||
← 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 %}
|
||||
@@ -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 →</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' });
|
||||
|
||||
140
app/modules/tenancy/templates/tenancy/merchant/team.html
Normal file
140
app/modules/tenancy/templates/tenancy/merchant/team.html
Normal 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 %}
|
||||
@@ -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">
|
||||
← Back to Login
|
||||
← {{ _("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="/">
|
||||
← 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="/">
|
||||
← {{ _("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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
255
app/modules/tenancy/tests/unit/test_merchant_store_service.py
Normal file
255
app/modules/tenancy/tests/unit/test_merchant_store_service.py
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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] }"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user