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:
|
if user_context.is_super_admin:
|
||||||
# Super admin: check user-level config
|
# Super admin: use platform from token if selected, else global (no filtering)
|
||||||
platform_id = None
|
platform_id = user_context.token_platform_id
|
||||||
user_id = user_context.id
|
user_id = None
|
||||||
else:
|
else:
|
||||||
# Platform admin: need platform context
|
# Platform admin: need platform context
|
||||||
# Try to get from request state
|
# Try to get from request state
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ analytics_module = ModuleDefinition(
|
|||||||
icon="chart-bar",
|
icon="chart-bar",
|
||||||
route="/store/{store_code}/analytics",
|
route="/store/{store_code}/analytics",
|
||||||
order=20,
|
order=20,
|
||||||
|
requires_permission="analytics.view",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
const response = await apiClient.get('/store/usage');
|
const response = await apiClient.get('/store/billing/usage');
|
||||||
this.usage = response;
|
this.usage = response;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ Requires customer authentication for order placement.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -92,15 +91,21 @@ def place_order(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update customer stats
|
# Update customer order stats (owned by orders module)
|
||||||
customer.total_orders = (customer.total_orders or 0) + 1
|
from app.modules.orders.services.customer_order_service import (
|
||||||
customer.total_spent = (customer.total_spent or 0) + order.total_amount
|
customer_order_service,
|
||||||
customer.last_order_date = datetime.now(UTC)
|
)
|
||||||
db.flush()
|
|
||||||
|
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(
|
logger.debug(
|
||||||
f"Updated customer stats: total_orders={customer.total_orders}, "
|
f"Updated customer order stats: total_orders={stats.total_orders}, "
|
||||||
f"total_spent={customer.total_spent}"
|
f"total_spent_cents={stats.total_spent_cents}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clear cart (get session_id from request cookies or headers)
|
# Clear cart (get session_id from request cookies or headers)
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ Usage:
|
|||||||
return [
|
return [
|
||||||
OnboardingStepDefinition(
|
OnboardingStepDefinition(
|
||||||
key="marketplace.connect_api",
|
key="marketplace.connect_api",
|
||||||
title_key="onboarding.marketplace.connect_api.title",
|
title_key="marketplace.onboarding.connect_api.title",
|
||||||
description_key="onboarding.marketplace.connect_api.description",
|
description_key="marketplace.onboarding.connect_api.description",
|
||||||
icon="plug",
|
icon="plug",
|
||||||
route_template="/store/{store_code}/letzshop",
|
route_template="/store/{store_code}/letzshop",
|
||||||
order=200,
|
order=200,
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ core_module = ModuleDefinition(
|
|||||||
"dashboard",
|
"dashboard",
|
||||||
"settings",
|
"settings",
|
||||||
"email-templates",
|
"email-templates",
|
||||||
"my-menu",
|
|
||||||
],
|
],
|
||||||
FrontendType.STORE: [
|
FrontendType.STORE: [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
@@ -112,15 +111,6 @@ core_module = ModuleDefinition(
|
|||||||
order=10,
|
order=10,
|
||||||
is_mandatory=True,
|
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",
|
"dashboard": "Dashboard",
|
||||||
"platform_settings": "Plattform-Einstellungen",
|
"platform_settings": "Plattform-Einstellungen",
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"my_menu": "Mein Menü",
|
|
||||||
"account_settings": "Kontoeinstellungen",
|
"account_settings": "Kontoeinstellungen",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen"
|
||||||
|
|||||||
@@ -92,7 +92,6 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"platform_settings": "Platform Settings",
|
"platform_settings": "Platform Settings",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"my_menu": "My Menu",
|
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Account Settings",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
|
|||||||
@@ -72,7 +72,6 @@
|
|||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"platform_settings": "Paramètres de la plateforme",
|
"platform_settings": "Paramètres de la plateforme",
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
"my_menu": "Mon menu",
|
|
||||||
"account_settings": "Paramètres du compte",
|
"account_settings": "Paramètres du compte",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"settings": "Paramètres"
|
"settings": "Paramètres"
|
||||||
|
|||||||
@@ -72,7 +72,6 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"platform_settings": "Plattform-Astellungen",
|
"platform_settings": "Plattform-Astellungen",
|
||||||
"general": "Allgemeng",
|
"general": "Allgemeng",
|
||||||
"my_menu": "Mäi Menü",
|
|
||||||
"account_settings": "Kont-Astellungen",
|
"account_settings": "Kont-Astellungen",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"settings": "Astellungen"
|
"settings": "Astellungen"
|
||||||
|
|||||||
@@ -100,12 +100,15 @@ class AdminMenuConfig(Base, TimestampMixin):
|
|||||||
comment="Platform scope - applies to users/stores of this platform",
|
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(
|
user_id = Column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
index=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
|
- GET /menu-config/platforms/{platform_id} - Get menu config for a platform
|
||||||
- PUT /menu-config/platforms/{platform_id} - Update menu visibility 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
|
- 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/admin - Get rendered admin menu for current user
|
||||||
- GET /menu/store - Get rendered store menu for current platform
|
- 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"}
|
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")
|
@router.post("/platforms/{platform_id}/show-all")
|
||||||
async def show_all_platform_menu_config(
|
async def show_all_platform_menu_config(
|
||||||
platform_id: int = Path(..., description="Platform ID"),
|
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.
|
Used by the frontend to render the sidebar.
|
||||||
"""
|
"""
|
||||||
if current_user.is_super_admin:
|
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(
|
menu = menu_service.get_menu_for_rendering(
|
||||||
db=db,
|
db=db,
|
||||||
frontend_type=FrontendType.ADMIN,
|
frontend_type=FrontendType.ADMIN,
|
||||||
user_id=current_user.id,
|
platform_id=platform_id,
|
||||||
is_super_admin=True,
|
is_super_admin=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ Merchant menu rendering endpoint.
|
|||||||
Provides the dynamic sidebar menu for the merchant portal:
|
Provides the dynamic sidebar menu for the merchant portal:
|
||||||
- GET /menu/render/merchant - Get rendered merchant menu for current user
|
- GET /menu/render/merchant - Get rendered merchant menu for current user
|
||||||
|
|
||||||
Menu sections are driven by module definitions (FrontendType.MERCHANT).
|
Menu sections are grouped by platform:
|
||||||
Only modules enabled on platforms the merchant is actively subscribed to
|
- Core items (dashboard, billing, account) appear at root level
|
||||||
will appear in the sidebar.
|
- 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
|
import logging
|
||||||
@@ -38,6 +40,9 @@ class MenuSectionResponse(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
label: str | None = None
|
label: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
platform_code: str | None = None
|
||||||
|
is_collapsible: bool = False
|
||||||
items: list[dict[str, Any]]
|
items: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
@@ -83,10 +88,12 @@ async def get_rendered_merchant_menu(
|
|||||||
"""
|
"""
|
||||||
Get the rendered merchant menu for the current user.
|
Get the rendered merchant menu for the current user.
|
||||||
|
|
||||||
Returns the filtered menu structure based on modules enabled
|
Returns a platform-grouped menu structure:
|
||||||
on platforms the merchant is subscribed to.
|
- 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)
|
# Resolve the merchant for this user (via service layer)
|
||||||
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
|
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
|
||||||
@@ -98,32 +105,25 @@ async def get_rendered_merchant_menu(
|
|||||||
sections=[],
|
sections=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get union of enabled module codes across all subscribed platforms
|
# Get platform-grouped menu
|
||||||
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
|
core_sections, platform_sections = menu_service.get_merchant_menu_by_platform(
|
||||||
|
|
||||||
# Resolve primary platform for AdminMenuConfig visibility lookup
|
|
||||||
primary_platform_id = menu_service.get_merchant_primary_platform_id(
|
|
||||||
db, merchant.id
|
db, merchant.id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get filtered menu using enabled_module_codes override + platform visibility
|
|
||||||
menu = menu_service.get_menu_for_rendering(
|
|
||||||
db=db,
|
|
||||||
frontend_type=FrontendType.MERCHANT,
|
|
||||||
platform_id=primary_platform_id,
|
|
||||||
enabled_module_codes=enabled_codes,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve language
|
# Resolve language
|
||||||
language = current_user.preferred_language or getattr(
|
language = current_user.preferred_language or getattr(
|
||||||
request.state, "language", "en"
|
request.state, "language", "en"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Translate section and item labels
|
# Build response sections
|
||||||
sections = []
|
all_sections = []
|
||||||
for section in menu:
|
|
||||||
|
# Core sections first (translated labels)
|
||||||
|
for section in core_sections:
|
||||||
translated_items = []
|
translated_items = []
|
||||||
for item in section.items:
|
for item in section.items:
|
||||||
|
if not item.is_module_enabled:
|
||||||
|
continue
|
||||||
translated_items.append(
|
translated_items.append(
|
||||||
{
|
{
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
@@ -133,15 +133,59 @@ async def get_rendered_merchant_menu(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
sections.append(
|
if translated_items:
|
||||||
MenuSectionResponse(
|
all_sections.append(
|
||||||
id=section.id,
|
MenuSectionResponse(
|
||||||
label=_translate_label(section.label_key, language),
|
id=section.id,
|
||||||
items=translated_items,
|
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(
|
return RenderedMenuResponse(
|
||||||
frontend_type=FrontendType.MERCHANT.value,
|
frontend_type=FrontendType.MERCHANT.value,
|
||||||
sections=sections,
|
sections=all_sections,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -224,14 +224,6 @@ def get_store_settings(
|
|||||||
"is_verified": domain.is_verified,
|
"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 {
|
return {
|
||||||
# General info
|
# General info
|
||||||
"store_code": store.store_code,
|
"store_code": store.store_code,
|
||||||
@@ -297,9 +289,6 @@ def get_store_settings(
|
|||||||
"domains": domains,
|
"domains": domains,
|
||||||
"default_subdomain": f"{store.subdomain}.letzshop.lu",
|
"default_subdomain": f"{store.subdomain}.letzshop.lu",
|
||||||
|
|
||||||
# Stripe info (read-only)
|
|
||||||
"stripe_info": stripe_info,
|
|
||||||
|
|
||||||
# Options for dropdowns
|
# Options for dropdowns
|
||||||
"options": {
|
"options": {
|
||||||
"supported_languages": SUPPORTED_LANGUAGES,
|
"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.enums import FrontendType
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
from app.utils.i18n import get_jinja2_globals
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -57,10 +58,13 @@ async def admin_login_page(
|
|||||||
if current_user:
|
if current_user:
|
||||||
return RedirectResponse(url="/admin/dashboard", status_code=302)
|
return RedirectResponse(url="/admin/dashboard", status_code=302)
|
||||||
|
|
||||||
return templates.TemplateResponse("tenancy/admin/login.html", {
|
language = getattr(request.state, "language", "en")
|
||||||
|
context = {
|
||||||
"request": request,
|
"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)
|
@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),
|
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
|
Platform admins with access to multiple platforms must select
|
||||||
which platform they want to manage before accessing the dashboard.
|
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:
|
if not current_user:
|
||||||
return RedirectResponse(url="/admin/login", status_code=302)
|
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(
|
return templates.TemplateResponse(
|
||||||
"tenancy/admin/select-platform.html",
|
"tenancy/admin/select-platform.html",
|
||||||
{"request": request, "user": current_user},
|
{"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)
|
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def admin_features_page(
|
async def admin_features_page(
|
||||||
request: Request,
|
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.enums import FrontendType
|
||||||
from app.modules.tenancy.schemas.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
from app.utils.i18n import get_jinja2_globals
|
||||||
|
|
||||||
ROUTE_CONFIG = {
|
ROUTE_CONFIG = {
|
||||||
"prefix": "",
|
"prefix": "",
|
||||||
@@ -67,10 +68,13 @@ async def merchant_login_page(
|
|||||||
if current_user:
|
if current_user:
|
||||||
return RedirectResponse(url="/merchants/dashboard", status_code=302)
|
return RedirectResponse(url="/merchants/dashboard", status_code=302)
|
||||||
|
|
||||||
return templates.TemplateResponse("tenancy/merchant/login.html", {
|
language = getattr(request.state, "language", "fr")
|
||||||
|
context = {
|
||||||
"request": request,
|
"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
|
# 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(
|
def get_merchant_enabled_module_codes(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -472,60 +569,6 @@ class MenuService:
|
|||||||
|
|
||||||
return result
|
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(
|
def update_menu_visibility(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -692,54 +735,6 @@ class MenuService:
|
|||||||
f"Created {len(all_items) - len(mandatory_items)} hidden records for platform {platform_id}"
|
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(
|
def show_all_platform_menu_config(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -789,52 +784,6 @@ class MenuService:
|
|||||||
f"Created {len(all_items) - len(mandatory_items)} visible records for platform {platform_id}"
|
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(
|
def initialize_menu_config(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -39,11 +39,28 @@ class OnboardingAggregatorService:
|
|||||||
a unified interface for the dashboard onboarding banner.
|
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(
|
def _get_enabled_providers(
|
||||||
self, db: Session, platform_id: int
|
self, db: Session, store_id: int, platform_id: int
|
||||||
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
|
) -> 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:
|
Returns:
|
||||||
List of (module, provider) tuples for enabled modules with providers
|
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.registry import MODULES
|
||||||
from app.modules.service import module_service
|
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]] = []
|
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
|
||||||
|
|
||||||
for module in MODULES.values():
|
for module in MODULES.values():
|
||||||
@@ -59,13 +81,19 @@ class OnboardingAggregatorService:
|
|||||||
|
|
||||||
# Core modules are always enabled, check others
|
# Core modules are always enabled, check others
|
||||||
if not module.is_core:
|
if not module.is_core:
|
||||||
try:
|
# Check if module is enabled on ANY of the store's subscribed platforms
|
||||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
enabled_on_any = False
|
||||||
continue
|
for pid in store_platform_ids:
|
||||||
except Exception as e:
|
try:
|
||||||
logger.warning(
|
if module_service.is_module_enabled(db, pid, module.code):
|
||||||
f"Failed to check if module {module.code} is enabled: {e}"
|
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
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -88,7 +116,7 @@ class OnboardingAggregatorService:
|
|||||||
Returns:
|
Returns:
|
||||||
Sorted list of OnboardingStepStatus objects
|
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] = []
|
steps: list[OnboardingStepStatus] = []
|
||||||
|
|
||||||
for module, provider in providers:
|
for module, provider in providers:
|
||||||
|
|||||||
@@ -52,15 +52,33 @@ class StatsAggregatorService:
|
|||||||
when modules are disabled or providers fail.
|
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(
|
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]]:
|
) -> list[tuple["ModuleDefinition", MetricsProviderProtocol]]:
|
||||||
"""
|
"""
|
||||||
Get metrics providers from enabled modules.
|
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:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
platform_id: Platform ID to check module enablement
|
platform_id: Platform ID to check module enablement
|
||||||
|
store_id: Optional store ID for subscription-aware filtering
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of (module, provider) tuples for enabled modules with providers
|
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.registry import MODULES
|
||||||
from app.modules.service import module_service
|
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]] = []
|
providers: list[tuple[ModuleDefinition, MetricsProviderProtocol]] = []
|
||||||
|
|
||||||
for module in MODULES.values():
|
for module in MODULES.values():
|
||||||
@@ -77,13 +103,18 @@ class StatsAggregatorService:
|
|||||||
|
|
||||||
# Core modules are always enabled, check others
|
# Core modules are always enabled, check others
|
||||||
if not module.is_core:
|
if not module.is_core:
|
||||||
try:
|
enabled_on_any = False
|
||||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
for pid in check_platform_ids:
|
||||||
continue
|
try:
|
||||||
except Exception as e:
|
if module_service.is_module_enabled(db, pid, module.code):
|
||||||
logger.warning(
|
enabled_on_any = True
|
||||||
f"Failed to check if module {module.code} is enabled: {e}"
|
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
|
continue
|
||||||
|
|
||||||
# Get the provider instance
|
# Get the provider instance
|
||||||
@@ -119,7 +150,7 @@ class StatsAggregatorService:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict mapping category name to list of MetricValue objects
|
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]] = {}
|
result: dict[str, list[MetricValue]] = {}
|
||||||
|
|
||||||
for module, provider in providers:
|
for module, provider in providers:
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ function languageSelector(currentLang, enabledLanguages) {
|
|||||||
return {
|
return {
|
||||||
isLangOpen: false,
|
isLangOpen: false,
|
||||||
currentLang: currentLang || 'fr',
|
currentLang: currentLang || 'fr',
|
||||||
languages: enabledLanguages || ['fr', 'de', 'en'],
|
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
||||||
languageNames: {
|
languageNames: {
|
||||||
'en': 'English',
|
'en': 'English',
|
||||||
'fr': 'Français',
|
'fr': 'Français',
|
||||||
|
|||||||
@@ -6,6 +6,27 @@
|
|||||||
// Create custom logger for login page
|
// Create custom logger for login page
|
||||||
const loginLog = window.LogConfig.createLogger('LOGIN');
|
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() {
|
function adminLogin() {
|
||||||
return {
|
return {
|
||||||
dark: false,
|
dark: false,
|
||||||
@@ -17,6 +38,10 @@ function adminLogin() {
|
|||||||
error: null,
|
error: null,
|
||||||
success: null,
|
success: null,
|
||||||
errors: {},
|
errors: {},
|
||||||
|
rememberMe: false,
|
||||||
|
showForgotPassword: false,
|
||||||
|
forgotPasswordEmail: '',
|
||||||
|
forgotPasswordLoading: false,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Guard against multiple initialization
|
// Guard against multiple initialization
|
||||||
@@ -196,6 +221,14 @@ function adminLogin() {
|
|||||||
window.location.href = '/admin/select-platform';
|
window.location.href = '/admin/select-platform';
|
||||||
return;
|
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) {
|
} catch (platformError) {
|
||||||
loginLog.warn('Could not check platforms, proceeding to dashboard:', 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() {
|
toggleDarkMode() {
|
||||||
loginLog.debug('Toggling dark mode...');
|
loginLog.debug('Toggling dark mode...');
|
||||||
this.dark = !this.dark;
|
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
|
// Use centralized logger
|
||||||
const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN');
|
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() {
|
function merchantLogin() {
|
||||||
return {
|
return {
|
||||||
dark: false,
|
dark: false,
|
||||||
@@ -134,6 +155,7 @@ function merchantLogin() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Forgot password state
|
// Forgot password state
|
||||||
|
rememberMe: false,
|
||||||
showForgotPassword: false,
|
showForgotPassword: false,
|
||||||
forgotPasswordEmail: '',
|
forgotPasswordEmail: '',
|
||||||
forgotPasswordLoading: false,
|
forgotPasswordLoading: false,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
|
|||||||
* Fetches onboarding steps from API, supports session-scoped dismiss.
|
* Fetches onboarding steps from API, supports session-scoped dismiss.
|
||||||
*/
|
*/
|
||||||
function onboardingBanner() {
|
function onboardingBanner() {
|
||||||
|
const t = (key, vars) => I18n.t(key, vars);
|
||||||
return {
|
return {
|
||||||
|
t,
|
||||||
visible: false,
|
visible: false,
|
||||||
steps: [],
|
steps: [],
|
||||||
totalSteps: 0,
|
totalSteps: 0,
|
||||||
@@ -30,7 +32,19 @@ function onboardingBanner() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/store/dashboard/onboarding');
|
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.totalSteps = response.total_steps || 0;
|
||||||
this.completedSteps = response.completed_steps || 0;
|
this.completedSteps = response.completed_steps || 0;
|
||||||
this.progressPercentage = response.progress_percentage || 0;
|
this.progressPercentage = response.progress_percentage || 0;
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ function languageSelector(currentLang, enabledLanguages) {
|
|||||||
return {
|
return {
|
||||||
isLangOpen: false,
|
isLangOpen: false,
|
||||||
currentLang: currentLang || 'fr',
|
currentLang: currentLang || 'fr',
|
||||||
languages: enabledLanguages || ['fr', 'de', 'en'],
|
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
||||||
languageNames: {
|
languageNames: {
|
||||||
'en': 'English',
|
'en': 'English',
|
||||||
'fr': 'Français',
|
'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 %}
|
{% 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 %}
|
{% block content %}
|
||||||
<!-- Email Settings Warning -->
|
<!-- 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(
|
def test_loyalty_appears_when_module_enabled(
|
||||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
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)
|
response = client.get(BASE, headers=menu_auth)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
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"]}
|
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(
|
def test_loyalty_hidden_when_module_not_enabled(
|
||||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
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)
|
response = client.get(BASE, headers=menu_auth)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
platform_section_id = f"platform-{menu_platform.code}"
|
||||||
section_ids = {s["id"] for s in data["sections"]}
|
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(
|
def test_loyalty_item_has_correct_route(
|
||||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||||
menu_loyalty_module,
|
menu_loyalty_module, menu_platform,
|
||||||
):
|
):
|
||||||
"""Loyalty overview item has the correct URL."""
|
"""Loyalty overview item has the correct URL."""
|
||||||
response = client.get(BASE, headers=menu_auth)
|
response = client.get(BASE, headers=menu_auth)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
loyalty = next(s for s in data["sections"] if s["id"] == "loyalty")
|
platform_section_id = f"platform-{menu_platform.code}"
|
||||||
overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview")
|
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"
|
assert overview["url"] == "/merchants/loyalty/overview"
|
||||||
|
|
||||||
|
|
||||||
@@ -352,7 +367,8 @@ class TestMerchantMenuSubscriptionStatus:
|
|||||||
response = client.get(BASE, headers=menu_auth)
|
response = client.get(BASE, headers=menu_auth)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
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(
|
def test_expired_subscription_hides_non_core_modules(
|
||||||
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
|
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
|
||||||
@@ -374,8 +390,9 @@ class TestMerchantMenuSubscriptionStatus:
|
|||||||
response = client.get(BASE, headers=menu_auth)
|
response = client.get(BASE, headers=menu_auth)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
section_ids = {s["id"] for s in response.json()["sections"]}
|
||||||
# Loyalty should NOT appear because subscription is expired
|
platform_section_id = f"platform-{menu_platform.code}"
|
||||||
assert "loyalty" not in section_ids
|
# Platform section should NOT appear because subscription is expired
|
||||||
|
assert platform_section_id not in section_ids
|
||||||
# Core sections always appear
|
# Core sections always appear
|
||||||
assert "main" in section_ids
|
assert "main" in section_ids
|
||||||
assert "billing" in section_ids
|
assert "billing" in section_ids
|
||||||
@@ -468,9 +485,20 @@ class TestMerchantMenuMultiPlatform:
|
|||||||
|
|
||||||
response = client.get(BASE, headers=menu_auth)
|
response = client.get(BASE, headers=menu_auth)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
data = response.json()
|
||||||
# Loyalty enabled on Platform A should appear in the union
|
section_ids = {s["id"] for s in data["sections"]}
|
||||||
assert "loyalty" in section_ids
|
# 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
|
# Core sections always present
|
||||||
assert "main" in section_ids
|
assert "main" in section_ids
|
||||||
assert "billing" in section_ids
|
assert "billing" in section_ids
|
||||||
|
|||||||
@@ -110,3 +110,62 @@ class TestMenuServiceMerchantRendering:
|
|||||||
without_ids = {s.id for s in without_loyalty}
|
without_ids = {s.id for s in without_loyalty}
|
||||||
assert "loyalty" in with_ids
|
assert "loyalty" in with_ids
|
||||||
assert "loyalty" not in without_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={
|
menus={
|
||||||
FrontendType.ADMIN: [
|
FrontendType.ADMIN: [
|
||||||
MenuSectionDefinition(
|
MenuSectionDefinition(
|
||||||
id="storeOps",
|
id="userManagement",
|
||||||
label_key="customers.menu.store_operations",
|
label_key="customers.menu.user_management",
|
||||||
icon="user-group",
|
icon="user-group",
|
||||||
order=40,
|
order=10,
|
||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="customers",
|
id="customers",
|
||||||
label_key="customers.menu.customers",
|
label_key="customers.menu.customers",
|
||||||
icon="user-group",
|
icon="user-group",
|
||||||
route="/admin/customers",
|
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,
|
JSON,
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
Numeric,
|
|
||||||
String,
|
String,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -41,9 +39,6 @@ class Customer(Base, TimestampMixin):
|
|||||||
) # Store-specific ID
|
) # Store-specific ID
|
||||||
preferences = Column(JSON, default=dict)
|
preferences = Column(JSON, default=dict)
|
||||||
marketing_consent = Column(Boolean, default=False)
|
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)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
# Language preference (NULL = use store storefront_language default)
|
# Language preference (NULL = use store storefront_language default)
|
||||||
|
|||||||
@@ -44,3 +44,25 @@ async def store_customers_page(
|
|||||||
"customers/store/customers.html",
|
"customers/store/customers.html",
|
||||||
get_store_context(request, db, current_user, store_code),
|
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 datetime import datetime
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
@@ -45,11 +44,6 @@ class CustomerContext(BaseModel):
|
|||||||
marketing_consent: bool = False
|
marketing_consent: bool = False
|
||||||
preferred_language: str | None = None
|
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
|
# Status
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
@@ -89,9 +83,6 @@ class CustomerContext(BaseModel):
|
|||||||
phone=customer.phone,
|
phone=customer.phone,
|
||||||
marketing_consent=customer.marketing_consent,
|
marketing_consent=customer.marketing_consent,
|
||||||
preferred_language=customer.preferred_language,
|
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,
|
is_active=customer.is_active,
|
||||||
created_at=customer.created_at,
|
created_at=customer.created_at,
|
||||||
updated_at=customer.updated_at,
|
updated_at=customer.updated_at,
|
||||||
|
|||||||
@@ -111,9 +111,6 @@ class CustomerResponse(BaseModel):
|
|||||||
customer_number: str
|
customer_number: str
|
||||||
marketing_consent: bool
|
marketing_consent: bool
|
||||||
preferred_language: str | None
|
preferred_language: str | None
|
||||||
last_order_date: datetime | None
|
|
||||||
total_orders: int
|
|
||||||
total_spent: Decimal
|
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -291,10 +288,6 @@ class CustomerStatisticsResponse(BaseModel):
|
|||||||
total: int = 0
|
total: int = 0
|
||||||
active: int = 0
|
active: int = 0
|
||||||
inactive: 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
|
customer_number: str
|
||||||
marketing_consent: bool = False
|
marketing_consent: bool = False
|
||||||
preferred_language: str | None = None
|
preferred_language: str | None = None
|
||||||
last_order_date: datetime | None = None
|
|
||||||
total_orders: int = 0
|
|
||||||
total_spent: float = 0.0
|
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Handles customer operations for admin users across all stores.
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.customers.exceptions import CustomerNotFoundException
|
from app.modules.customers.exceptions import CustomerNotFoundException
|
||||||
@@ -98,9 +97,6 @@ class AdminCustomerService:
|
|||||||
"customer_number": customer.customer_number,
|
"customer_number": customer.customer_number,
|
||||||
"marketing_consent": customer.marketing_consent,
|
"marketing_consent": customer.marketing_consent,
|
||||||
"preferred_language": customer.preferred_language,
|
"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,
|
"is_active": customer.is_active,
|
||||||
"created_at": customer.created_at,
|
"created_at": customer.created_at,
|
||||||
"updated_at": customer.updated_at,
|
"updated_at": customer.updated_at,
|
||||||
@@ -134,25 +130,11 @@ class AdminCustomerService:
|
|||||||
total = query.count()
|
total = query.count()
|
||||||
active = query.filter(Customer.is_active == True).count() # noqa: E712
|
active = query.filter(Customer.is_active == True).count() # noqa: E712
|
||||||
inactive = query.filter(Customer.is_active == False).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 {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"active": active,
|
"active": active,
|
||||||
"inactive": inactive,
|
"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(
|
def get_customer(
|
||||||
@@ -195,9 +177,6 @@ class AdminCustomerService:
|
|||||||
"customer_number": customer.customer_number,
|
"customer_number": customer.customer_number,
|
||||||
"marketing_consent": customer.marketing_consent,
|
"marketing_consent": customer.marketing_consent,
|
||||||
"preferred_language": customer.preferred_language,
|
"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,
|
"is_active": customer.is_active,
|
||||||
"created_at": customer.created_at,
|
"created_at": customer.created_at,
|
||||||
"updated_at": customer.updated_at,
|
"updated_at": customer.updated_at,
|
||||||
|
|||||||
@@ -431,26 +431,6 @@ class CustomerService:
|
|||||||
|
|
||||||
return customer
|
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(
|
def _generate_customer_number(
|
||||||
self, db: Session, store_id: int, store_code: str
|
self, db: Session, store_id: int, store_code: str
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ function adminCustomers() {
|
|||||||
stats: {
|
stats: {
|
||||||
total: 0,
|
total: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
inactive: 0,
|
inactive: 0
|
||||||
with_orders: 0,
|
|
||||||
total_spent: 0,
|
|
||||||
total_orders: 0,
|
|
||||||
avg_order_value: 0
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Pagination (standard structure matching pagination macro)
|
// 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
|
* 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
|
// Modal states
|
||||||
showDetailModal: false,
|
showDetailModal: false,
|
||||||
showOrdersModal: false,
|
|
||||||
selectedCustomer: null,
|
selectedCustomer: null,
|
||||||
customerOrders: [],
|
|
||||||
|
|
||||||
// Debounce timer
|
// Debounce timer
|
||||||
searchTimeout: null,
|
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
|
* 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
|
* Pagination: Previous page
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
{{ error_state('Error loading customers') }}
|
{{ error_state('Error loading customers') }}
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- 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 -->
|
<!-- Card: Total Customers -->
|
||||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
<!-- Search and Filters -->
|
||||||
@@ -134,8 +104,6 @@
|
|||||||
<th class="px-4 py-3">Customer</th>
|
<th class="px-4 py-3">Customer</th>
|
||||||
<th class="px-4 py-3">Store</th>
|
<th class="px-4 py-3">Store</th>
|
||||||
<th class="px-4 py-3">Customer #</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">Status</th>
|
||||||
<th class="px-4 py-3">Joined</th>
|
<th class="px-4 py-3">Joined</th>
|
||||||
<th class="px-4 py-3">Actions</th>
|
<th class="px-4 py-3">Actions</th>
|
||||||
@@ -145,7 +113,7 @@
|
|||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<template x-if="loadingCustomers && customers.length === 0">
|
<template x-if="loadingCustomers && customers.length === 0">
|
||||||
<tr>
|
<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>
|
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||||
<p>Loading customers...</p>
|
<p>Loading customers...</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -155,7 +123,7 @@
|
|||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<template x-if="!loadingCustomers && customers.length === 0">
|
<template x-if="!loadingCustomers && customers.length === 0">
|
||||||
<tr>
|
<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>
|
<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="font-medium">No customers found</p>
|
||||||
<p class="text-sm mt-1">Try adjusting your search or filters</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>
|
<span class="font-mono text-xs" x-text="customer.customer_number"></span>
|
||||||
</td>
|
</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 -->
|
<!-- Status -->
|
||||||
<td class="px-4 py-3 text-xs">
|
<td class="px-4 py-3 text-xs">
|
||||||
<span
|
<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">Customer</th>
|
||||||
<th class="px-4 py-3">Email</th>
|
<th class="px-4 py-3">Email</th>
|
||||||
<th class="px-4 py-3">Joined</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>
|
<th class="px-4 py-3">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -130,8 +129,6 @@
|
|||||||
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
|
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
|
||||||
<!-- Joined -->
|
<!-- Joined -->
|
||||||
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
|
<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 -->
|
<!-- Actions -->
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center space-x-2 text-sm">
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
@@ -142,13 +139,6 @@
|
|||||||
>
|
>
|
||||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
@click="messageCustomer(customer)"
|
@click="messageCustomer(customer)"
|
||||||
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
|
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
|
||||||
@@ -162,7 +152,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<tr x-show="customers.length === 0">
|
<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">
|
<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>
|
<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>
|
<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>
|
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
|
<p class="text-gray-500 dark:text-gray-400">Customer #</p>
|
||||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
|
<p class="font-medium font-mono text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.customer_number || '-'"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
|
<p class="text-gray-500 dark:text-gray-400">Status</p>
|
||||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></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>
|
</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">
|
<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
|
Close
|
||||||
</button>
|
</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">
|
<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">
|
||||||
Send Message
|
View Full Profile
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{# app/templates/storefront/account/forgot-password.html #}
|
{# app/templates/storefront/account/forgot-password.html #}
|
||||||
{# standalone #}
|
{# standalone #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
|
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="{{ current_language|default('fr') }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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 -->
|
<!-- Fonts: Local fallback + Google Fonts -->
|
||||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
<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" />
|
<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>
|
<div class="text-6xl mb-4">🔐</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h2 class="text-2xl font-bold text-white mb-2">{{ store.name }}</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,11 +67,11 @@
|
|||||||
<template x-if="!emailSent">
|
<template x-if="!emailSent">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Forgot Password
|
{{ _("auth.reset_password") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
@@ -84,14 +83,14 @@
|
|||||||
<!-- Forgot Password Form -->
|
<!-- Forgot Password Form -->
|
||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
<label class="block text-sm">
|
<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"
|
<input x-model="email"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@input="clearErrors"
|
@input="clearErrors"
|
||||||
type="email"
|
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="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 }"
|
:class="{ 'border-red-600': errors.email }"
|
||||||
placeholder="your@email.com"
|
placeholder="{{ _('auth.email_placeholder') }}"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
required />
|
required />
|
||||||
<span x-show="errors.email" x-text="errors.email"
|
<span x-show="errors.email" x-text="errors.email"
|
||||||
@@ -100,10 +99,13 @@
|
|||||||
|
|
||||||
<button type="submit" :disabled="loading"
|
<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">
|
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 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>
|
<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">
|
||||||
Sending...
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -114,24 +116,25 @@
|
|||||||
<template x-if="emailSent">
|
<template x-if="emailSent">
|
||||||
<div class="text-center">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Check Your Email
|
{{ _("auth.check_email") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
<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>.
|
{{ _("auth.reset_link_sent") }}
|
||||||
Please check your inbox and click the link to reset your password.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<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"
|
<button @click="emailSent = false"
|
||||||
class="font-medium hover:underline"
|
class="font-medium hover:underline"
|
||||||
style="color: var(--color-primary);">
|
style="color: var(--color-primary);">
|
||||||
try again
|
{{ _("auth.try_again") }}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,19 +143,34 @@
|
|||||||
<hr class="my-8" />
|
<hr class="my-8" />
|
||||||
|
|
||||||
<p class="mt-4 text-center">
|
<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"
|
<a class="text-sm font-medium hover:underline ml-1"
|
||||||
style="color: var(--color-primary);"
|
style="color: var(--color-primary);"
|
||||||
href="{{ base_url }}account/login">
|
href="{{ base_url }}account/login">
|
||||||
Sign in
|
{{ _("auth.sign_in") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-center">
|
<p class="mt-2 text-center">
|
||||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||||
href="{{ base_url }}">
|
href="{{ base_url }}">
|
||||||
← Continue shopping
|
← {{ _("auth.continue_shopping") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,6 +182,22 @@
|
|||||||
|
|
||||||
<!-- Forgot Password Logic -->
|
<!-- Forgot Password Logic -->
|
||||||
<script>
|
<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() {
|
function forgotPassword() {
|
||||||
return {
|
return {
|
||||||
// Data
|
// Data
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Customer Login
|
{{ _("auth.customer_login") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Success Message (after registration) -->
|
<!-- Success Message (after registration) -->
|
||||||
@@ -82,14 +82,14 @@
|
|||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin">
|
||||||
<label class="block text-sm">
|
<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"
|
<input x-model="credentials.email"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@input="clearAllErrors"
|
@input="clearAllErrors"
|
||||||
type="email"
|
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="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 }"
|
:class="{ 'border-red-600': errors.email }"
|
||||||
placeholder="your@email.com"
|
placeholder="{{ _('auth.email_placeholder') }}"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
required />
|
required />
|
||||||
<span x-show="errors.email" x-text="errors.email"
|
<span x-show="errors.email" x-text="errors.email"
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block mt-4 text-sm">
|
<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">
|
<div class="relative">
|
||||||
<input x-model="credentials.password"
|
<input x-model="credentials.password"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
:type="showPassword ? 'text' : 'password'"
|
: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="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 }"
|
:class="{ 'border-red-600': errors.password }"
|
||||||
placeholder="Enter your password"
|
placeholder="{{ _('auth.password_placeholder') }}"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required />
|
required />
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -125,21 +125,21 @@
|
|||||||
x-model="rememberMe"
|
x-model="rememberMe"
|
||||||
class="form-checkbox focus-primary focus:outline-none"
|
class="form-checkbox focus-primary focus:outline-none"
|
||||||
style="color: var(--color-primary);">
|
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>
|
</label>
|
||||||
<a href="{{ base_url }}account/forgot-password"
|
<a href="{{ base_url }}account/forgot-password"
|
||||||
class="text-sm font-medium hover:underline"
|
class="text-sm font-medium hover:underline"
|
||||||
style="color: var(--color-primary);">
|
style="color: var(--color-primary);">
|
||||||
Forgot password?
|
{{ _("auth.forgot_password") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" :disabled="loading"
|
<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">
|
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 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>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -147,19 +147,34 @@
|
|||||||
<hr class="my-8" />
|
<hr class="my-8" />
|
||||||
|
|
||||||
<p class="mt-4 text-center">
|
<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"
|
<a class="text-sm font-medium hover:underline ml-1"
|
||||||
style="color: var(--color-primary);"
|
style="color: var(--color-primary);"
|
||||||
href="{{ base_url }}account/register">
|
href="{{ base_url }}account/register">
|
||||||
Create an account
|
{{ _("auth.create_account") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-center">
|
<p class="mt-2 text-center">
|
||||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||||
href="{{ base_url }}">
|
href="{{ base_url }}">
|
||||||
← Continue shopping
|
← {{ _("auth.continue_shopping") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,6 +186,22 @@
|
|||||||
|
|
||||||
<!-- Login Logic -->
|
<!-- Login Logic -->
|
||||||
<script>
|
<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() {
|
function customerLogin() {
|
||||||
return {
|
return {
|
||||||
// Data
|
// Data
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Create Account
|
{{ _("auth.create_account_title") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<!-- First Name -->
|
<!-- First Name -->
|
||||||
<label class="block text-sm">
|
<label class="block text-sm">
|
||||||
<span class="text-gray-700 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
<input x-model="formData.first_name"
|
<input x-model="formData.first_name"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<!-- Last Name -->
|
<!-- Last Name -->
|
||||||
<label class="block mt-4 text-sm">
|
<label class="block mt-4 text-sm">
|
||||||
<span class="text-gray-700 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
<input x-model="formData.last_name"
|
<input x-model="formData.last_name"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
<label class="block mt-4 text-sm">
|
<label class="block mt-4 text-sm">
|
||||||
<span class="text-gray-700 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
<input x-model="formData.email"
|
<input x-model="formData.email"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
|
|
||||||
<!-- Phone (Optional) -->
|
<!-- Phone (Optional) -->
|
||||||
<label class="block mt-4 text-sm">
|
<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"
|
<input x-model="formData.phone"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
type="tel"
|
type="tel"
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<label class="block mt-4 text-sm">
|
<label class="block mt-4 text-sm">
|
||||||
<span class="text-gray-700 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input x-model="formData.password"
|
<input x-model="formData.password"
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
<span x-show="errors.password" x-text="errors.password"
|
<span x-show="errors.password" x-text="errors.password"
|
||||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
<!-- Confirm Password -->
|
<!-- Confirm Password -->
|
||||||
<label class="block mt-4 text-sm">
|
<label class="block mt-4 text-sm">
|
||||||
<span class="text-gray-700 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
<input x-model="confirmPassword"
|
<input x-model="confirmPassword"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -198,16 +198,16 @@
|
|||||||
class="form-checkbox focus-primary focus:outline-none mt-1"
|
class="form-checkbox focus-primary focus:outline-none mt-1"
|
||||||
style="color: var(--color-primary);">
|
style="color: var(--color-primary);">
|
||||||
<label for="marketingConsent" class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" :disabled="loading"
|
<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">
|
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 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>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -215,13 +215,28 @@
|
|||||||
<hr class="my-8" />
|
<hr class="my-8" />
|
||||||
|
|
||||||
<p class="mt-4 text-center">
|
<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"
|
<a class="text-sm font-medium hover:underline ml-1"
|
||||||
style="color: var(--color-primary);"
|
style="color: var(--color-primary);"
|
||||||
href="{{ base_url }}account/login">
|
href="{{ base_url }}account/login">
|
||||||
Sign in instead
|
{{ _("auth.sign_in_instead") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,6 +248,22 @@
|
|||||||
|
|
||||||
<!-- Registration Logic -->
|
<!-- Registration Logic -->
|
||||||
<script>
|
<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() {
|
function customerRegistration() {
|
||||||
return {
|
return {
|
||||||
// Data
|
// Data
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
Unit tests for AdminCustomerService.
|
Unit tests for AdminCustomerService.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.modules.customers.exceptions import CustomerNotFoundException
|
from app.modules.customers.exceptions import CustomerNotFoundException
|
||||||
@@ -18,16 +16,6 @@ def admin_customer_service():
|
|||||||
return AdminCustomerService()
|
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
|
@pytest.fixture
|
||||||
def multiple_customers(db, test_store):
|
def multiple_customers(db, test_store):
|
||||||
"""Create multiple customers for testing."""
|
"""Create multiple customers for testing."""
|
||||||
@@ -41,8 +29,6 @@ def multiple_customers(db, test_store):
|
|||||||
last_name=f"Last{i}",
|
last_name=f"Last{i}",
|
||||||
customer_number=f"CUST-00{i}",
|
customer_number=f"CUST-00{i}",
|
||||||
is_active=(i % 2 == 0), # Alternate active/inactive
|
is_active=(i % 2 == 0), # Alternate active/inactive
|
||||||
total_orders=i,
|
|
||||||
total_spent=Decimal(str(i * 100)),
|
|
||||||
)
|
)
|
||||||
db.add(customer) # noqa: PERF006
|
db.add(customer) # noqa: PERF006
|
||||||
customers.append(customer)
|
customers.append(customer)
|
||||||
@@ -165,10 +151,6 @@ class TestAdminCustomerServiceStats:
|
|||||||
assert stats["total"] == 0
|
assert stats["total"] == 0
|
||||||
assert stats["active"] == 0
|
assert stats["active"] == 0
|
||||||
assert stats["inactive"] == 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(
|
def test_get_customer_stats_with_data(
|
||||||
self, db, admin_customer_service, multiple_customers
|
self, db, admin_customer_service, multiple_customers
|
||||||
@@ -179,12 +161,6 @@ class TestAdminCustomerServiceStats:
|
|||||||
assert stats["total"] == 5
|
assert stats["total"] == 5
|
||||||
assert stats["active"] == 3 # 0, 2, 4
|
assert stats["active"] == 3 # 0, 2, 4
|
||||||
assert stats["inactive"] == 2 # 1, 3
|
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(
|
def test_get_customer_stats_by_store(
|
||||||
self, db, admin_customer_service, test_customer, test_store
|
self, db, admin_customer_service, test_customer, test_store
|
||||||
@@ -194,16 +170,6 @@ class TestAdminCustomerServiceStats:
|
|||||||
|
|
||||||
assert stats["total"] == 1
|
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
|
@pytest.mark.unit
|
||||||
class TestAdminCustomerServiceGetCustomer:
|
class TestAdminCustomerServiceGetCustomer:
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ class TestCustomerModel:
|
|||||||
|
|
||||||
assert customer.is_active is True # Default
|
assert customer.is_active is True # Default
|
||||||
assert customer.marketing_consent is False # 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):
|
def test_customer_full_name_property(self, db, test_store):
|
||||||
"""Test Customer full_name computed property."""
|
"""Test Customer full_name computed property."""
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ class TestCustomerResponseSchema:
|
|||||||
def test_from_dict(self):
|
def test_from_dict(self):
|
||||||
"""Test creating response from dict."""
|
"""Test creating response from dict."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -161,9 +160,6 @@ class TestCustomerResponseSchema:
|
|||||||
"customer_number": "CUST001",
|
"customer_number": "CUST001",
|
||||||
"marketing_consent": False,
|
"marketing_consent": False,
|
||||||
"preferred_language": "fr",
|
"preferred_language": "fr",
|
||||||
"last_order_date": None,
|
|
||||||
"total_orders": 5,
|
|
||||||
"total_spent": Decimal("500.00"),
|
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"created_at": datetime.now(),
|
"created_at": datetime.now(),
|
||||||
"updated_at": datetime.now(),
|
"updated_at": datetime.now(),
|
||||||
@@ -171,7 +167,7 @@ class TestCustomerResponseSchema:
|
|||||||
response = CustomerResponse(**data)
|
response = CustomerResponse(**data)
|
||||||
assert response.id == 1
|
assert response.id == 1
|
||||||
assert response.customer_number == "CUST001"
|
assert response.customer_number == "CUST001"
|
||||||
assert response.total_orders == 5
|
assert response.preferred_language == "fr"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ MANDATORY_MENU_ITEMS = {
|
|||||||
"stores",
|
"stores",
|
||||||
"admin-users",
|
"admin-users",
|
||||||
"settings",
|
"settings",
|
||||||
"my-menu", # Super admin menu config - must always be accessible
|
|
||||||
}),
|
}),
|
||||||
FrontendType.STORE: frozenset({
|
FrontendType.STORE: frozenset({
|
||||||
"dashboard", # Default landing page after login
|
"dashboard", # Default landing page after login
|
||||||
|
|||||||
@@ -90,5 +90,11 @@
|
|||||||
"view_rewards_desc": "Prämien und Einlösungen anzeigen",
|
"view_rewards_desc": "Prämien und Einlösungen anzeigen",
|
||||||
"manage_rewards": "Prämien verwalten",
|
"manage_rewards": "Prämien verwalten",
|
||||||
"manage_rewards_desc": "Treueprämien erstellen und 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",
|
"statistics": "Statistics",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"settings": "Settings"
|
"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",
|
"view_rewards_desc": "Voir les récompenses et les échanges",
|
||||||
"manage_rewards": "Gérer les récompenses",
|
"manage_rewards": "Gérer les récompenses",
|
||||||
"manage_rewards_desc": "Créer et gérer les récompenses de fidélité"
|
"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",
|
"view_rewards_desc": "Belounungen an Aléisunge kucken",
|
||||||
"manage_rewards": "Beloununge verwalten",
|
"manage_rewards": "Beloununge verwalten",
|
||||||
"manage_rewards_desc": "Treiheet-Belounungen erstellen a 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 [
|
return [
|
||||||
OnboardingStepDefinition(
|
OnboardingStepDefinition(
|
||||||
key="loyalty.create_program",
|
key="loyalty.create_program",
|
||||||
title_key="onboarding.loyalty.create_program.title",
|
title_key="loyalty.onboarding.create_program.title",
|
||||||
description_key="onboarding.loyalty.create_program.description",
|
description_key="loyalty.onboarding.create_program.description",
|
||||||
icon="gift",
|
icon="gift",
|
||||||
route_template="/store/{store_code}/loyalty/programs",
|
route_template="/store/{store_code}/loyalty/programs",
|
||||||
order=300,
|
order=300,
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ function merchantLoyaltySettings() {
|
|||||||
if (window._merchantLoyaltySettingsInitialized) return;
|
if (window._merchantLoyaltySettingsInitialized) return;
|
||||||
window._merchantLoyaltySettingsInitialized = true;
|
window._merchantLoyaltySettingsInitialized = true;
|
||||||
|
|
||||||
|
// Load sidebar menu (from base data())
|
||||||
|
this.loadMenuConfig();
|
||||||
|
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
|
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ marketplace_module = ModuleDefinition(
|
|||||||
icon="download",
|
icon="download",
|
||||||
route="/store/{store_code}/marketplace",
|
route="/store/{store_code}/marketplace",
|
||||||
order=30,
|
order=30,
|
||||||
|
requires_permission="marketplace.view_integration",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -162,6 +163,7 @@ marketplace_module = ModuleDefinition(
|
|||||||
icon="external-link",
|
icon="external-link",
|
||||||
route="/store/{store_code}/letzshop",
|
route="/store/{store_code}/letzshop",
|
||||||
order=20,
|
order=20,
|
||||||
|
requires_permission="marketplace.view_integration",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -91,5 +91,15 @@
|
|||||||
"manage_integration_desc": "Marktplatz-Integration konfigurieren",
|
"manage_integration_desc": "Marktplatz-Integration konfigurieren",
|
||||||
"sync_products": "Produkte synchronisieren",
|
"sync_products": "Produkte synchronisieren",
|
||||||
"sync_products_desc": "Produkte mit dem Marktplatz 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": {
|
"menu": {
|
||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"letzshop": "Letzshop",
|
"letzshop": "Letzshop",
|
||||||
"products_inventory": "Products & Inventory",
|
"products_inventory": "Products & Inventory",
|
||||||
"marketplace_import": "Marketplace Import",
|
"marketplace_import": "Marketplace Import",
|
||||||
"sales_orders": "Sales & Orders",
|
"sales_orders": "Sales & Orders",
|
||||||
"letzshop_orders": "Letzshop Orders"
|
"letzshop_orders": "Letzshop Orders"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"view_integration": "View Integration",
|
"view_integration": "View Integration",
|
||||||
"view_integration_desc": "View marketplace integration settings",
|
"view_integration_desc": "View marketplace integration settings",
|
||||||
"manage_integration": "Manage Integration",
|
"manage_integration": "Manage Integration",
|
||||||
"manage_integration_desc": "Configure marketplace integration",
|
"manage_integration_desc": "Configure marketplace integration",
|
||||||
"sync_products": "Sync Products",
|
"sync_products": "Sync Products",
|
||||||
"sync_products_desc": "Synchronize products with marketplace"
|
"sync_products_desc": "Synchronize products with marketplace"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"sync": "Sync",
|
"sync": "Sync",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"source_url": "Source URL",
|
"source_url": "Source URL",
|
||||||
"import_products": "Import Products",
|
"import_products": "Import Products",
|
||||||
"start_import": "Start Import",
|
"start_import": "Start Import",
|
||||||
"importing": "Importing...",
|
"importing": "Importing...",
|
||||||
"import_complete": "Import Complete",
|
"import_complete": "Import Complete",
|
||||||
"import_failed": "Import Failed",
|
"import_failed": "Import Failed",
|
||||||
"import_history": "Import History",
|
"import_history": "Import History",
|
||||||
"job_id": "Job ID",
|
"job_id": "Job ID",
|
||||||
"started_at": "Started At",
|
"started_at": "Started At",
|
||||||
"completed_at": "Completed At",
|
"completed_at": "Completed At",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"imported_count": "Imported",
|
"imported_count": "Imported",
|
||||||
"error_count": "Errors",
|
"error_count": "Errors",
|
||||||
"total_processed": "Total Processed",
|
"total_processed": "Total Processed",
|
||||||
"progress": "Progress",
|
"progress": "Progress",
|
||||||
"no_import_jobs": "No import jobs yet",
|
"no_import_jobs": "No import jobs yet",
|
||||||
"start_first_import": "Start your first import using the form above"
|
"start_first_import": "Start your first import using the form above"
|
||||||
},
|
},
|
||||||
"letzshop": {
|
"letzshop": {
|
||||||
"title": "Letzshop Integration",
|
"title": "Letzshop Integration",
|
||||||
"connection": "Connection",
|
"connection": "Connection",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
"api_key": "API Key",
|
"api_key": "API Key",
|
||||||
"api_endpoint": "API Endpoint",
|
"api_endpoint": "API Endpoint",
|
||||||
"auto_sync": "Auto Sync",
|
"auto_sync": "Auto Sync",
|
||||||
"sync_interval": "Sync Interval",
|
"sync_interval": "Sync Interval",
|
||||||
"every_hour": "Every hour",
|
"every_hour": "Every hour",
|
||||||
"every_day": "Every day",
|
"every_day": "Every day",
|
||||||
"test_connection": "Test Connection",
|
"test_connection": "Test Connection",
|
||||||
"save_credentials": "Save Credentials",
|
"save_credentials": "Save Credentials",
|
||||||
"connection_success": "Connection successful",
|
"connection_success": "Connection successful",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
"last_sync": "Last Sync",
|
"last_sync": "Last Sync",
|
||||||
"sync_status": "Sync Status",
|
"sync_status": "Sync Status",
|
||||||
"import_orders": "Import Orders",
|
"import_orders": "Import Orders",
|
||||||
"export_products": "Export Products",
|
"export_products": "Export Products",
|
||||||
"no_credentials": "Configure your API key in Settings to get started",
|
"no_credentials": "Configure your API key in Settings to get started",
|
||||||
"carriers": {
|
"carriers": {
|
||||||
"dhl": "DHL",
|
"dhl": "DHL",
|
||||||
"ups": "UPS",
|
"ups": "UPS",
|
||||||
"fedex": "FedEx",
|
"fedex": "FedEx",
|
||||||
"dpd": "DPD",
|
"dpd": "DPD",
|
||||||
"gls": "GLS",
|
"gls": "GLS",
|
||||||
"post_luxembourg": "Post Luxembourg",
|
"post_luxembourg": "Post Luxembourg",
|
||||||
"other": "Other"
|
"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",
|
"manage_integration_desc": "Configurer l'intégration marketplace",
|
||||||
"sync_products": "Synchroniser les produits",
|
"sync_products": "Synchroniser les produits",
|
||||||
"sync_products_desc": "Synchroniser les produits avec le marketplace"
|
"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",
|
"manage_integration_desc": "Marché-Integratioun konfiguréieren",
|
||||||
"sync_products": "Produiten synchroniséieren",
|
"sync_products": "Produiten synchroniséieren",
|
||||||
"sync_products_desc": "Produiten mam Marché 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 [
|
return [
|
||||||
OnboardingStepDefinition(
|
OnboardingStepDefinition(
|
||||||
key="marketplace.connect_api",
|
key="marketplace.connect_api",
|
||||||
title_key="onboarding.marketplace.connect_api.title",
|
title_key="marketplace.onboarding.connect_api.title",
|
||||||
description_key="onboarding.marketplace.connect_api.description",
|
description_key="marketplace.onboarding.connect_api.description",
|
||||||
icon="plug",
|
icon="plug",
|
||||||
route_template="/store/{store_code}/letzshop",
|
route_template="/store/{store_code}/letzshop",
|
||||||
order=200,
|
order=200,
|
||||||
@@ -36,8 +36,8 @@ class MarketplaceOnboardingProvider:
|
|||||||
),
|
),
|
||||||
OnboardingStepDefinition(
|
OnboardingStepDefinition(
|
||||||
key="marketplace.import_products",
|
key="marketplace.import_products",
|
||||||
title_key="onboarding.marketplace.import_products.title",
|
title_key="marketplace.onboarding.import_products.title",
|
||||||
description_key="onboarding.marketplace.import_products.description",
|
description_key="marketplace.onboarding.import_products.description",
|
||||||
icon="package",
|
icon="package",
|
||||||
route_template="/store/{store_code}/marketplace",
|
route_template="/store/{store_code}/marketplace",
|
||||||
order=210,
|
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.
|
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 (
|
from app.modules.orders.models.invoice import (
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
@@ -15,6 +16,7 @@ from app.modules.orders.models.order import Order, OrderItem
|
|||||||
from app.modules.orders.models.order_item_exception import OrderItemException
|
from app.modules.orders.models.order_item_exception import OrderItemException
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CustomerOrderStats",
|
||||||
"Order",
|
"Order",
|
||||||
"OrderItem",
|
"OrderItem",
|
||||||
"OrderItemException",
|
"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
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.modules.orders.models import Order
|
from app.modules.orders.models import CustomerOrderStats, Order
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -89,6 +90,51 @@ class CustomerOrderService:
|
|||||||
.all()
|
.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(
|
def get_order_count(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
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",
|
route="/merchants/account/stores",
|
||||||
order=10,
|
order=10,
|
||||||
),
|
),
|
||||||
|
MenuItemDefinition(
|
||||||
|
id="team",
|
||||||
|
label_key="tenancy.menu.team",
|
||||||
|
icon="user-group",
|
||||||
|
route="/merchants/account/team",
|
||||||
|
order=15,
|
||||||
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="profile",
|
id="profile",
|
||||||
label_key="tenancy.menu.profile",
|
label_key="tenancy.menu.profile",
|
||||||
@@ -211,6 +218,7 @@ tenancy_module = ModuleDefinition(
|
|||||||
icon="user-group",
|
icon="user-group",
|
||||||
route="/store/{store_code}/team",
|
route="/store/{store_code}/team",
|
||||||
order=5,
|
order=5,
|
||||||
|
requires_permission="team.view",
|
||||||
),
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="roles",
|
id="roles",
|
||||||
|
|||||||
@@ -144,5 +144,11 @@
|
|||||||
"team_edit_desc": "Rollen und Berechtigungen der Mitglieder bearbeiten",
|
"team_edit_desc": "Rollen und Berechtigungen der Mitglieder bearbeiten",
|
||||||
"team_remove": "Mitglieder entfernen",
|
"team_remove": "Mitglieder entfernen",
|
||||||
"team_remove_desc": "Mitglieder aus dem Team 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",
|
"name": "Audit Log",
|
||||||
"description": "Track all user actions and changes"
|
"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_edit_desc": "Modifier les rôles et permissions des membres",
|
||||||
"team_remove": "Supprimer des membres",
|
"team_remove": "Supprimer des membres",
|
||||||
"team_remove_desc": "Retirer des membres de l'équipe"
|
"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_edit_desc": "Rollen a Rechter vun de Memberen änneren",
|
||||||
"team_remove": "Memberen ewechhuelen",
|
"team_remove": "Memberen ewechhuelen",
|
||||||
"team_remove_desc": "Memberen aus dem Team 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"
|
String(5), nullable=False, default="fr"
|
||||||
) # Default language for customer-facing storefront
|
) # Default language for customer-facing storefront
|
||||||
storefront_languages = Column(
|
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
|
) # Array of enabled languages for storefront language selector
|
||||||
|
|
||||||
# Currency/number formatting locale (e.g., 'fr-LU' = "29,99 EUR", 'en-GB' = "EUR29.99")
|
# 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,
|
"is_super_admin": current_user.is_super_admin,
|
||||||
"requires_platform_selection": not current_user.is_super_admin and len(platforms) > 0,
|
"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),
|
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.
|
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:
|
Args:
|
||||||
platform_id: Platform ID to select
|
platform_id: Platform ID to select
|
||||||
@@ -186,13 +187,9 @@ def select_platform(
|
|||||||
Returns:
|
Returns:
|
||||||
PlatformSelectResponse with new token and platform info
|
PlatformSelectResponse with new token and platform info
|
||||||
"""
|
"""
|
||||||
if current_user.is_super_admin:
|
# Platform admins must have access; super admins can access any platform
|
||||||
raise InvalidCredentialsException(
|
if not current_user.is_super_admin:
|
||||||
"Super admins don't need platform selection - they have global access"
|
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
|
||||||
)
|
|
||||||
|
|
||||||
# Verify admin has access to this platform (raises exception if not)
|
|
||||||
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
|
|
||||||
|
|
||||||
# Load platform
|
# Load platform
|
||||||
platform = admin_platform_service.get_platform_by_id(db, platform_id)
|
platform = admin_platform_service.get_platform_by_id(db, platform_id)
|
||||||
@@ -227,3 +224,45 @@ def select_platform(
|
|||||||
platform_id=platform.id,
|
platform_id=platform.id,
|
||||||
platform_code=platform.code,
|
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,
|
MerchantPortalProfileResponse,
|
||||||
MerchantPortalProfileUpdate,
|
MerchantPortalProfileUpdate,
|
||||||
MerchantPortalStoreListResponse,
|
MerchantPortalStoreListResponse,
|
||||||
|
MerchantStoreCreate,
|
||||||
|
MerchantStoreDetailResponse,
|
||||||
|
MerchantStoreUpdate,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.schemas.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
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 .email_verification import email_verification_api_router
|
||||||
from .merchant_auth import merchant_auth_router
|
from .merchant_auth import merchant_auth_router
|
||||||
@@ -63,14 +67,113 @@ async def merchant_stores(
|
|||||||
db, merchant.id, skip=skip, limit=limit
|
db, merchant.id, skip=skip, limit=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
can_create, _ = merchant_store_service.can_create_store(db, merchant.id)
|
||||||
|
|
||||||
return MerchantPortalStoreListResponse(
|
return MerchantPortalStoreListResponse(
|
||||||
stores=stores,
|
stores=stores,
|
||||||
total=total,
|
total=total,
|
||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
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)
|
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)
|
||||||
async def merchant_profile(
|
async def merchant_profile(
|
||||||
request: Request,
|
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)
|
@router.get("/profile", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def merchant_profile_page(
|
async def merchant_profile_page(
|
||||||
request: Request,
|
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.core.utils.page_context import get_store_context
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
from app.utils.i18n import get_jinja2_globals
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -71,11 +72,13 @@ async def store_login_page(
|
|||||||
url=f"/store/{store_code}/dashboard", status_code=302
|
url=f"/store/{store_code}/dashboard", status_code=302
|
||||||
)
|
)
|
||||||
|
|
||||||
|
language = getattr(request.state, "language", "fr")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"tenancy/store/login.html",
|
"tenancy/store/login.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"store_code": store_code,
|
"store_code": store_code,
|
||||||
|
**get_jinja2_globals(language),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ from app.modules.tenancy.schemas.merchant import (
|
|||||||
MerchantPortalProfileUpdate,
|
MerchantPortalProfileUpdate,
|
||||||
MerchantPortalStoreListResponse,
|
MerchantPortalStoreListResponse,
|
||||||
MerchantResponse,
|
MerchantResponse,
|
||||||
|
MerchantStoreCreate,
|
||||||
|
MerchantStoreDetailResponse,
|
||||||
|
MerchantStoreUpdate,
|
||||||
MerchantSummary,
|
MerchantSummary,
|
||||||
MerchantTransferOwnership,
|
MerchantTransferOwnership,
|
||||||
MerchantTransferOwnershipResponse,
|
MerchantTransferOwnershipResponse,
|
||||||
@@ -163,6 +166,9 @@ __all__ = [
|
|||||||
"MerchantPortalProfileUpdate",
|
"MerchantPortalProfileUpdate",
|
||||||
"MerchantPortalStoreListResponse",
|
"MerchantPortalStoreListResponse",
|
||||||
"MerchantResponse",
|
"MerchantResponse",
|
||||||
|
"MerchantStoreCreate",
|
||||||
|
"MerchantStoreDetailResponse",
|
||||||
|
"MerchantStoreUpdate",
|
||||||
"MerchantSummary",
|
"MerchantSummary",
|
||||||
"MerchantTransferOwnership",
|
"MerchantTransferOwnership",
|
||||||
"MerchantTransferOwnershipResponse",
|
"MerchantTransferOwnershipResponse",
|
||||||
|
|||||||
@@ -261,3 +261,73 @@ class MerchantPortalStoreListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
skip: int
|
skip: int
|
||||||
limit: 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 (limited)
|
||||||
"settings.view",
|
"settings.view",
|
||||||
"settings.theme",
|
"settings.theme",
|
||||||
|
# Team (view only)
|
||||||
|
"team.view",
|
||||||
# Imports
|
# Imports
|
||||||
"imports.view",
|
"imports.view",
|
||||||
"imports.create",
|
"imports.create",
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class TenancyOnboardingProvider:
|
|||||||
return [
|
return [
|
||||||
OnboardingStepDefinition(
|
OnboardingStepDefinition(
|
||||||
key="tenancy.customize_store",
|
key="tenancy.customize_store",
|
||||||
title_key="onboarding.tenancy.customize_store.title",
|
title_key="tenancy.onboarding.customize_store.title",
|
||||||
description_key="onboarding.tenancy.customize_store.description",
|
description_key="tenancy.onboarding.customize_store.description",
|
||||||
icon="settings",
|
icon="settings",
|
||||||
route_template="/store/{store_code}/settings",
|
route_template="/store/{store_code}/settings",
|
||||||
order=100,
|
order=100,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// static/admin/js/select-platform.js
|
// 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;
|
const platformLog = window.LogConfig ? window.LogConfig.createLogger('PLATFORM_SELECT') : console;
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ function selectPlatform() {
|
|||||||
error: null,
|
error: null,
|
||||||
platforms: [],
|
platforms: [],
|
||||||
isSuperAdmin: false,
|
isSuperAdmin: false,
|
||||||
|
currentPlatformId: null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
platformLog.info('=== PLATFORM SELECTION PAGE INITIALIZING ===');
|
platformLog.info('=== PLATFORM SELECTION PAGE INITIALIZING ===');
|
||||||
@@ -46,21 +47,14 @@ function selectPlatform() {
|
|||||||
const response = await apiClient.get('/admin/auth/accessible-platforms');
|
const response = await apiClient.get('/admin/auth/accessible-platforms');
|
||||||
platformLog.debug('Platforms response:', response);
|
platformLog.debug('Platforms response:', response);
|
||||||
|
|
||||||
this.isSuperAdmin = response.role === 'super_admin';
|
this.isSuperAdmin = response.is_super_admin === true;
|
||||||
this.platforms = response.platforms || [];
|
this.platforms = response.platforms || [];
|
||||||
|
this.currentPlatformId = response.current_platform_id || null;
|
||||||
|
|
||||||
if (this.isSuperAdmin) {
|
if (!this.isSuperAdmin && !response.requires_platform_selection && this.platforms.length === 1) {
|
||||||
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) {
|
|
||||||
// Only one platform assigned, auto-select it
|
// Only one platform assigned, auto-select it
|
||||||
platformLog.info('Single platform assigned, auto-selecting...');
|
platformLog.info('Single platform assigned, auto-selecting...');
|
||||||
await this.selectPlatform(this.platforms[0]);
|
await this.choosePlatform(this.platforms[0]);
|
||||||
return;
|
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;
|
if (this.selecting) return;
|
||||||
|
|
||||||
this.selecting = true;
|
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() {
|
async logout() {
|
||||||
platformLog.info('Logging out...');
|
platformLog.info('Logging out...');
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,27 @@
|
|||||||
// Create custom logger for store login page
|
// Create custom logger for store login page
|
||||||
const storeLoginLog = window.LogConfig.createLogger('STORE-LOGIN');
|
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() {
|
function storeLogin() {
|
||||||
return {
|
return {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -159,6 +180,7 @@ function storeLogin() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Forgot password state
|
// Forgot password state
|
||||||
|
rememberMe: false,
|
||||||
showForgotPassword: false,
|
showForgotPassword: false,
|
||||||
forgotPasswordEmail: '',
|
forgotPasswordEmail: '',
|
||||||
forgotPasswordLoading: false,
|
forgotPasswordLoading: false,
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function storeSettings() {
|
|||||||
{ id: 'invoices', label: 'Invoices', icon: 'document-text' },
|
{ id: 'invoices', label: 'Invoices', icon: 'document-text' },
|
||||||
{ id: 'branding', label: 'Branding', icon: 'color-swatch' },
|
{ id: 'branding', label: 'Branding', icon: 'color-swatch' },
|
||||||
{ id: 'domains', label: 'Domains', icon: 'globe-alt' },
|
{ id: 'domains', label: 'Domains', icon: 'globe-alt' },
|
||||||
{ id: 'api', label: 'API & Payments', icon: 'key' },
|
|
||||||
{ id: 'notifications', label: 'Notifications', icon: 'bell' },
|
{ id: 'notifications', label: 'Notifications', icon: 'bell' },
|
||||||
{ id: 'email', label: 'Email', icon: 'mail' }
|
{ 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="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Admin Login
|
{{ _("auth.admin_login") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Alert Messages -->
|
<!-- Alert Messages -->
|
||||||
@@ -45,80 +45,129 @@
|
|||||||
x-transition></div>
|
x-transition></div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||||
<label class="block text-sm">
|
<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"
|
<input x-model="credentials.username"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@input="clearErrors"
|
@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 }"
|
:class="{ 'border-red-600': errors.username }"
|
||||||
placeholder="Enter your username"
|
placeholder="{{ _('auth.username_placeholder') }}"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
required />
|
required />
|
||||||
<span x-show="errors.username" x-text="errors.username"
|
<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>
|
||||||
|
|
||||||
<label class="block mt-4 text-sm">
|
<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>
|
||||||
<input x-model="credentials.password"
|
<div class="relative" x-data="{ showPw: false }">
|
||||||
:disabled="loading"
|
<input x-model="credentials.password"
|
||||||
@input="clearErrors"
|
:disabled="loading"
|
||||||
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"
|
@input="clearErrors"
|
||||||
:class="{ 'border-red-600': errors.password }"
|
:type="showPw ? 'text' : 'password'"
|
||||||
placeholder="***************"
|
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"
|
||||||
type="password"
|
:class="{ 'border-red-600': errors.password }"
|
||||||
autocomplete="current-password"
|
placeholder="{{ _('auth.password_placeholder') }}"
|
||||||
required />
|
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"
|
<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>
|
</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 #}
|
{# noqa: FE-002 - Inline spinner SVG for loading state #}
|
||||||
<button type="submit" :disabled="loading"
|
<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">
|
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 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">
|
<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>
|
<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>
|
<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>
|
</svg>
|
||||||
Signing in...
|
{{ _("auth.signing_in") }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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" />
|
<hr class="my-8" />
|
||||||
|
|
||||||
<p class="mt-4">
|
<p class="mt-4 text-center">
|
||||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||||
href="#">
|
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||||
Forgot your password?
|
href="/">
|
||||||
|
{{ _("auth.visit_platform") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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"
|
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||||
href="/">
|
href="/">
|
||||||
← Back to Platform
|
← {{ _("auth.back_to_platform") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Language selector -->
|
<!-- Language selector -->
|
||||||
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
|
<div class="flex items-center justify-center gap-2 mt-6"
|
||||||
async setLang(lang) {
|
x-data='languageSelector("{{ request.state.language|default("en") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||||
await fetch('/api/v1/platform/language/set', {
|
<template x-for="lang in languages" :key="lang">
|
||||||
method: 'POST',
|
<button
|
||||||
headers: {'Content-Type': 'application/json'},
|
@click="setLanguage(lang)"
|
||||||
body: JSON.stringify({language: lang})
|
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||||
});
|
:class="currentLang === lang
|
||||||
window.location.reload();
|
? '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 @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
|
></button>
|
||||||
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
|
</template>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
|
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Configure which menu items are visible for admins, stores, and merchants on this platform.
|
Configure which menu items are visible for admins and stores on this platform.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" x-text="platform?.code?.toUpperCase()"></span>
|
<span class="px-3 py-1 text-sm font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" x-text="platform?.code?.toUpperCase()"></span>
|
||||||
@@ -52,17 +52,8 @@
|
|||||||
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
|
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
|
||||||
Store Frontend
|
Store Frontend
|
||||||
</button>
|
</button>
|
||||||
<button
|
{# Merchant frontend menu is driven by module enablement + subscriptions,
|
||||||
@click="frontendType = 'merchant'; loadPlatformMenuConfig()"
|
not by AdminMenuConfig visibility. No tab needed here. #}
|
||||||
:class="{
|
|
||||||
'bg-white dark:bg-gray-800 shadow': frontendType === 'merchant',
|
|
||||||
'text-gray-600 dark:text-gray-400': frontendType !== 'merchant'
|
|
||||||
}"
|
|
||||||
class="px-4 py-2 text-sm font-medium rounded-md transition-all"
|
|
||||||
>
|
|
||||||
<span x-html="$icon('lightning-bolt', 'w-4 h-4 inline mr-2')"></span>
|
|
||||||
Merchant Frontend
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,20 +37,44 @@
|
|||||||
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
|
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Super Admin Notice -->
|
<!-- Global Mode Card (Super Admins Only) -->
|
||||||
<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">
|
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-3">
|
||||||
<p class="text-blue-700 dark:text-blue-400">
|
<button
|
||||||
You are a Super Admin with access to all platforms. Redirecting to dashboard...
|
@click="deselectPlatform()"
|
||||||
</p>
|
: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>
|
</div>
|
||||||
|
|
||||||
<!-- Platform List -->
|
<!-- 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">
|
<template x-for="platform in platforms" :key="platform.id">
|
||||||
<button
|
<button
|
||||||
@click="selectPlatform(platform)"
|
@click="choosePlatform(platform)"
|
||||||
:disabled="selecting"
|
: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 -->
|
<!-- 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">
|
<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>
|
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="platform.code"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Arrow -->
|
<!-- Checkmark for active / Arrow for others -->
|
||||||
<div class="flex-shrink-0 ml-4">
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -93,8 +122,41 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- Language & Theme Toggle -->
|
||||||
<div class="mt-4 flex justify-center">
|
<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
|
<button
|
||||||
@click="toggleDarkMode()"
|
@click="toggleDarkMode()"
|
||||||
class="p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
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="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Merchant Login
|
{{ _("auth.merchant_login") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Alert Messages -->
|
<!-- Alert Messages -->
|
||||||
@@ -46,110 +46,129 @@
|
|||||||
x-transition></div>
|
x-transition></div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||||
<label class="block text-sm">
|
<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"
|
<input x-model="credentials.email"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@input="clearErrors"
|
@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 }"
|
:class="{ 'border-red-600': errors.email }"
|
||||||
placeholder="you@example.com"
|
placeholder="{{ _('auth.email_placeholder') }}"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
required />
|
required />
|
||||||
<span x-show="errors.email" x-text="errors.email"
|
<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>
|
||||||
|
|
||||||
<label class="block mt-4 text-sm">
|
<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>
|
||||||
<input x-model="credentials.password"
|
<div class="relative" x-data="{ showPw: false }">
|
||||||
:disabled="loading"
|
<input x-model="credentials.password"
|
||||||
@input="clearErrors"
|
:disabled="loading"
|
||||||
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"
|
@input="clearErrors"
|
||||||
:class="{ 'border-red-600': errors.password }"
|
:type="showPw ? 'text' : 'password'"
|
||||||
placeholder="***************"
|
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"
|
||||||
type="password"
|
:class="{ 'border-red-600': errors.password }"
|
||||||
autocomplete="current-password"
|
placeholder="{{ _('auth.password_placeholder') }}"
|
||||||
required />
|
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"
|
<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>
|
</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 #}
|
{# noqa: FE-002 - Inline spinner SVG for loading state #}
|
||||||
<button type="submit" :disabled="loading"
|
<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">
|
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 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">
|
<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>
|
<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>
|
<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>
|
</svg>
|
||||||
Signing in...
|
{{ _("auth.signing_in") }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr class="my-8" />
|
|
||||||
|
|
||||||
<!-- Forgot Password Form -->
|
<!-- Forgot Password Form -->
|
||||||
<div x-show="showForgotPassword" x-transition>
|
<div x-show="showForgotPassword" x-cloak x-transition class="mt-6">
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
|
<hr class="mb-6" />
|
||||||
<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>
|
<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">
|
<form @submit.prevent="handleForgotPassword">
|
||||||
<label class="block text-sm">
|
<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"
|
<input x-model="forgotPasswordEmail"
|
||||||
:disabled="forgotPasswordLoading"
|
: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"
|
placeholder="{{ _('auth.email_placeholder') }}"
|
||||||
type="email"
|
type="email"
|
||||||
required />
|
required />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" :disabled="forgotPasswordLoading"
|
<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">
|
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">{{ _("auth.send_reset_link") }}</span>
|
||||||
<span x-show="forgotPasswordLoading">Sending...</span>
|
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="mt-4">
|
<p class="mt-4">
|
||||||
<a @click.prevent="showForgotPassword = false"
|
<a @click.prevent="showForgotPassword = false"
|
||||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||||
← Back to Login
|
← {{ _("auth.back_to_login") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="!showForgotPassword">
|
<hr class="my-8" />
|
||||||
<p class="mt-4">
|
|
||||||
<a @click.prevent="showForgotPassword = true"
|
<p class="mt-4 text-center">
|
||||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||||
Forgot your password?
|
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||||
</a>
|
href="/">
|
||||||
</p>
|
{{ _("auth.visit_platform") }}
|
||||||
<p class="mt-2">
|
</a>
|
||||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
</p>
|
||||||
href="/">
|
<p class="mt-2 text-center">
|
||||||
← Back to Platform
|
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||||
</a>
|
href="/">
|
||||||
</p>
|
← {{ _("auth.back_to_platform") }}
|
||||||
</div>
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Language selector -->
|
<!-- Language selector -->
|
||||||
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
|
<div class="flex items-center justify-center gap-2 mt-6"
|
||||||
async setLang(lang) {
|
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||||
await fetch('/api/v1/platform/language/set', {
|
<template x-for="lang in languages" :key="lang">
|
||||||
method: 'POST',
|
<button
|
||||||
headers: {'Content-Type': 'application/json'},
|
@click="setLanguage(lang)"
|
||||||
body: JSON.stringify({language: lang})
|
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||||
});
|
:class="currentLang === lang
|
||||||
window.location.reload();
|
? '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 @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
|
></button>
|
||||||
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
|
</template>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</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()">
|
<div x-data="merchantStores()">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8 flex items-center justify-between">
|
||||||
<h2 class="text-2xl font-bold text-gray-900">My Stores</h2>
|
<div>
|
||||||
<p class="mt-1 text-gray-500">View and manage your connected stores.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
@@ -17,6 +29,11 @@
|
|||||||
<p class="text-sm text-red-800" x-text="error"></p>
|
<p class="text-sm text-red-800" x-text="error"></p>
|
||||||
</div>
|
</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 -->
|
<!-- Loading -->
|
||||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
<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">
|
<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"/>
|
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>
|
</svg>
|
||||||
<h3 class="text-lg font-semibold text-gray-700">No stores yet</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Store Cards Grid -->
|
<!-- Store Cards Grid -->
|
||||||
<div x-show="!loading && stores.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<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">
|
<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">
|
<div class="p-6">
|
||||||
<!-- Store Name and Status -->
|
<!-- Store Name and Status -->
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div>
|
<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>
|
<p class="text-sm text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-green-100 text-green-800': store.status === 'active',
|
'bg-green-100 text-green-800': store.is_active,
|
||||||
'bg-yellow-100 text-yellow-800': store.status === 'pending',
|
'bg-gray-100 text-gray-600': !store.is_active
|
||||||
'bg-gray-100 text-gray-600': store.status === 'inactive',
|
|
||||||
'bg-red-100 text-red-800': store.status === 'suspended'
|
|
||||||
}"
|
}"
|
||||||
x-text="(store.status || 'active').toUpperCase()"></span>
|
x-text="store.is_active ? 'ACTIVE' : 'INACTIVE'"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<dl class="space-y-2 text-sm">
|
<dl class="space-y-2 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<dt class="text-gray-500">Store Code</dt>
|
<dt class="text-gray-500">Subdomain</dt>
|
||||||
<dd class="font-medium text-gray-900" x-text="store.store_code"></dd>
|
<dd class="font-medium text-gray-900" x-text="store.subdomain || '-'"></dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<dt class="text-gray-500">Created</dt>
|
<dt class="text-gray-500">Created</dt>
|
||||||
<dd class="font-medium text-gray-900" x-text="formatDate(store.created_at)"></dd>
|
<dd class="font-medium text-gray-900" x-text="formatDate(store.created_at)"></dd>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
</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>
|
</template>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -86,7 +248,20 @@ function merchantStores() {
|
|||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
successMessage: null,
|
||||||
stores: [],
|
stores: [],
|
||||||
|
canCreateStore: false,
|
||||||
|
showCreateModal: false,
|
||||||
|
creating: false,
|
||||||
|
createError: null,
|
||||||
|
availablePlatforms: [],
|
||||||
|
createForm: {
|
||||||
|
name: '',
|
||||||
|
store_code: '',
|
||||||
|
subdomain: '',
|
||||||
|
description: '',
|
||||||
|
platform_ids: [],
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.loadStores();
|
this.loadStores();
|
||||||
@@ -96,6 +271,7 @@ function merchantStores() {
|
|||||||
try {
|
try {
|
||||||
const data = await apiClient.get('/merchants/account/stores');
|
const data = await apiClient.get('/merchants/account/stores');
|
||||||
this.stores = data.stores || data.items || [];
|
this.stores = data.stores || data.items || [];
|
||||||
|
this.canCreateStore = data.can_create_store !== false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading stores:', err);
|
console.error('Error loading stores:', err);
|
||||||
this.error = 'Failed to load stores. Please try again.';
|
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) {
|
formatDate(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
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>
|
</template>
|
||||||
|
|
||||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
Store Portal Login
|
{{ _("auth.store_login") }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Alert Messages -->
|
<!-- Alert Messages -->
|
||||||
@@ -54,42 +54,63 @@
|
|||||||
|
|
||||||
<!-- Login Form (only show if store found) -->
|
<!-- Login Form (only show if store found) -->
|
||||||
<template x-if="store">
|
<template x-if="store">
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||||
<label class="block text-sm">
|
<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"
|
<input x-model="credentials.username"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@input="clearErrors"
|
@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 }"
|
:class="{ 'border-red-600': errors.username }"
|
||||||
placeholder="Enter your username"
|
placeholder="{{ _('auth.username_placeholder') }}"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
required />
|
required />
|
||||||
<span x-show="errors.username" x-text="errors.username"
|
<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>
|
||||||
|
|
||||||
<label class="block mt-4 text-sm">
|
<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>
|
||||||
<input x-model="credentials.password"
|
<div class="relative" x-data="{ showPw: false }">
|
||||||
:disabled="loading"
|
<input x-model="credentials.password"
|
||||||
@input="clearErrors"
|
:disabled="loading"
|
||||||
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"
|
@input="clearErrors"
|
||||||
:class="{ 'border-red-600': errors.password }"
|
:type="showPw ? 'text' : 'password'"
|
||||||
placeholder="***************"
|
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"
|
||||||
type="password"
|
:class="{ 'border-red-600': errors.password }"
|
||||||
autocomplete="current-password"
|
placeholder="{{ _('auth.password_placeholder') }}"
|
||||||
required />
|
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"
|
<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>
|
</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"
|
<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">
|
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 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>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -117,49 +138,64 @@
|
|||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading store information...</p>
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading store information...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-8" />
|
|
||||||
|
|
||||||
<!-- Forgot Password Form -->
|
<!-- Forgot Password Form -->
|
||||||
<div x-show="showForgotPassword" x-transition>
|
<div x-show="store && showForgotPassword" x-cloak x-transition class="mt-6">
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
|
<hr class="mb-6" />
|
||||||
<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>
|
<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">
|
<form @submit.prevent="handleForgotPassword">
|
||||||
<label class="block text-sm">
|
<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"
|
<input x-model="forgotPasswordEmail"
|
||||||
:disabled="forgotPasswordLoading"
|
: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"
|
placeholder="you@example.com"
|
||||||
type="email"
|
type="email"
|
||||||
required />
|
required />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" :disabled="forgotPasswordLoading"
|
<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">
|
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">{{ _("auth.send_reset_link") }}</span>
|
||||||
<span x-show="forgotPasswordLoading">Sending...</span>
|
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="mt-4">
|
<p class="mt-4">
|
||||||
<a @click.prevent="showForgotPassword = false"
|
<a @click.prevent="showForgotPassword = false"
|
||||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||||
← Back to Login
|
← {{ _("auth.back_to_login") }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="!showForgotPassword">
|
<hr class="my-8" />
|
||||||
<p class="mt-4">
|
|
||||||
<a @click.prevent="showForgotPassword = true"
|
<p class="mt-4 text-center">
|
||||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||||
Forgot your password?
|
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||||
</a>
|
href="/">
|
||||||
</p>
|
{{ _("auth.visit_platform") }}
|
||||||
<p class="mt-2">
|
</a>
|
||||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
</p>
|
||||||
href="/">
|
<p class="mt-2 text-center">
|
||||||
← Back to Platform
|
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||||
</a>
|
href="/">
|
||||||
</p>
|
← {{ _("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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
{{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }}
|
{{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }}
|
||||||
{{ tab_button('email', 'Email', tab_var='activeSection', icon='mail') }}
|
{{ tab_button('email', 'Email', tab_var='activeSection', icon='mail') }}
|
||||||
{{ tab_button('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }}
|
{{ 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') }}
|
{{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
@@ -1274,79 +1273,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Notification Settings -->
|
||||||
<div x-show="activeSection === 'notifications'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<ul class="flex items-center flex-shrink-0 space-x-6">
|
<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()">
|
<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">
|
<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 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 x-text="platform.name"></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -34,10 +38,31 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isSuperAdmin">
|
<!-- Super Admin + Global mode (no platform) -->
|
||||||
<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">
|
<template x-if="isSuperAdmin && !platform">
|
||||||
Super Admin
|
<div class="flex items-center space-x-2">
|
||||||
</span>
|
<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>
|
</template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -55,10 +80,11 @@
|
|||||||
<button
|
<button
|
||||||
@click="isLangOpen = !isLangOpen"
|
@click="isLangOpen = !isLangOpen"
|
||||||
@click.outside="isLangOpen = false"
|
@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"
|
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>
|
</button>
|
||||||
<div
|
<div
|
||||||
x-show="isLangOpen"
|
x-show="isLangOpen"
|
||||||
@@ -263,7 +289,7 @@ function platformContext() {
|
|||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(storedUser);
|
const user = JSON.parse(storedUser);
|
||||||
this.isSuperAdmin = user.role === 'super_admin';
|
this.isSuperAdmin = user.is_super_admin === true || user.role === 'super_admin';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to parse stored user:', e);
|
console.warn('Failed to parse stored user:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,11 @@
|
|||||||
<button
|
<button
|
||||||
@click="isLangOpen = !isLangOpen"
|
@click="isLangOpen = !isLangOpen"
|
||||||
@click.outside="isLangOpen = false"
|
@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"
|
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>
|
</button>
|
||||||
<div
|
<div
|
||||||
x-show="isLangOpen"
|
x-show="isLangOpen"
|
||||||
|
|||||||
@@ -52,7 +52,13 @@
|
|||||||
@click="toggleSection(section.id)"
|
@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"
|
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
|
<span
|
||||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||||
:class="{ 'rotate-180': openSections[section.id] }"
|
:class="{ 'rotate-180': openSections[section.id] }"
|
||||||
|
|||||||
@@ -86,10 +86,11 @@
|
|||||||
@click="isOpen = !isOpen"
|
@click="isOpen = !isOpen"
|
||||||
@click.outside="isOpen = false"
|
@click.outside="isOpen = false"
|
||||||
type="button"
|
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 }"
|
: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>
|
</button>
|
||||||
<div
|
<div
|
||||||
x-show="isOpen"
|
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