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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,27 @@
// Create custom logger for store login page
const storeLoginLog = window.LogConfig.createLogger('STORE-LOGIN');
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
try {
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
} catch (error) {
storeLoginLog.error('Failed to set language:', error);
}
},
};
}
function storeLogin() {
return {
credentials: {
@@ -159,6 +180,7 @@ function storeLogin() {
},
// Forgot password state
rememberMe: false,
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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