feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
All checks were successful
- Fix platform-grouped merchant sidebar menu with core items at root level - Add merchant store management (detail page, create store, team page) - Fix store settings 500 error by removing dead stripe/API tab - Move onboarding translations to module-owned locale files - Fix onboarding banner i18n with server-side rendering + context inheritance - Refactor login language selectors to use languageSelector() function (LANG-002) - Move HTTPException handling to global exception handler in merchant routes (API-003) - Add language selector to all login pages and portal headers - Fix customer module: drop order stats from customer model, add to orders module - Fix admin menu config visibility for super admin platform context - Fix storefront auth and layout issues - Add missing i18n translations for onboarding steps (en/fr/de/lb) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -188,6 +188,13 @@ tenancy_module = ModuleDefinition(
|
||||
route="/merchants/account/stores",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="team",
|
||||
label_key="tenancy.menu.team",
|
||||
icon="user-group",
|
||||
route="/merchants/account/team",
|
||||
order=15,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="profile",
|
||||
label_key="tenancy.menu.profile",
|
||||
@@ -211,6 +218,7 @@ tenancy_module = ModuleDefinition(
|
||||
icon="user-group",
|
||||
route="/store/{store_code}/team",
|
||||
order=5,
|
||||
requires_permission="team.view",
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="roles",
|
||||
|
||||
@@ -144,5 +144,11 @@
|
||||
"team_edit_desc": "Rollen und Berechtigungen der Mitglieder bearbeiten",
|
||||
"team_remove": "Mitglieder entfernen",
|
||||
"team_remove_desc": "Mitglieder aus dem Team entfernen"
|
||||
},
|
||||
"onboarding": {
|
||||
"customize_store": {
|
||||
"title": "Shop anpassen",
|
||||
"description": "Fügen Sie eine Beschreibung und ein Logo hinzu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,5 +151,11 @@
|
||||
"name": "Audit Log",
|
||||
"description": "Track all user actions and changes"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"customize_store": {
|
||||
"title": "Customize your store",
|
||||
"description": "Add a description and logo to your store"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,5 +144,11 @@
|
||||
"team_edit_desc": "Modifier les rôles et permissions des membres",
|
||||
"team_remove": "Supprimer des membres",
|
||||
"team_remove_desc": "Retirer des membres de l'équipe"
|
||||
},
|
||||
"onboarding": {
|
||||
"customize_store": {
|
||||
"title": "Personnalisez votre boutique",
|
||||
"description": "Ajoutez une description et un logo à votre boutique"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,5 +144,11 @@
|
||||
"team_edit_desc": "Rollen a Rechter vun de Memberen änneren",
|
||||
"team_remove": "Memberen ewechhuelen",
|
||||
"team_remove_desc": "Memberen aus dem Team ewechhuelen"
|
||||
},
|
||||
"onboarding": {
|
||||
"customize_store": {
|
||||
"title": "Äre Buttek personaliséieren",
|
||||
"description": "Setzt eng Beschreiwung an e Logo fir Äre Buttek derbäi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class Store(Base, TimestampMixin):
|
||||
String(5), nullable=False, default="fr"
|
||||
) # Default language for customer-facing storefront
|
||||
storefront_languages = Column(
|
||||
JSON, nullable=False, default=["fr", "de", "en"]
|
||||
JSON, nullable=False, default=["fr", "de", "en", "lb"]
|
||||
) # Array of enabled languages for storefront language selector
|
||||
|
||||
# Currency/number formatting locale (e.g., 'fr-LU' = "29,99 EUR", 'en-GB' = "EUR29.99")
|
||||
|
||||
@@ -164,6 +164,7 @@ def get_accessible_platforms(
|
||||
],
|
||||
"is_super_admin": current_user.is_super_admin,
|
||||
"requires_platform_selection": not current_user.is_super_admin and len(platforms) > 0,
|
||||
"current_platform_id": current_user.token_platform_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -175,10 +176,10 @@ def select_platform(
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Select platform context for platform admin.
|
||||
Select platform context for an admin.
|
||||
|
||||
Issues a new JWT token with platform context.
|
||||
Super admins skip this step (they have global access).
|
||||
Available to both platform admins and super admins.
|
||||
|
||||
Args:
|
||||
platform_id: Platform ID to select
|
||||
@@ -186,13 +187,9 @@ def select_platform(
|
||||
Returns:
|
||||
PlatformSelectResponse with new token and platform info
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
raise InvalidCredentialsException(
|
||||
"Super admins don't need platform selection - they have global access"
|
||||
)
|
||||
|
||||
# Verify admin has access to this platform (raises exception if not)
|
||||
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
|
||||
# Platform admins must have access; super admins can access any platform
|
||||
if not current_user.is_super_admin:
|
||||
admin_platform_service.validate_admin_platform_access(current_user, platform_id)
|
||||
|
||||
# Load platform
|
||||
platform = admin_platform_service.get_platform_by_id(db, platform_id)
|
||||
@@ -227,3 +224,45 @@ def select_platform(
|
||||
platform_id=platform.id,
|
||||
platform_code=platform.code,
|
||||
)
|
||||
|
||||
|
||||
@admin_auth_router.post("/deselect-platform")
|
||||
def deselect_platform(
|
||||
response: Response,
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Deselect platform context (return to global mode).
|
||||
|
||||
Only available to super admins. Issues a new JWT without platform context.
|
||||
|
||||
Returns:
|
||||
New token without platform context
|
||||
"""
|
||||
if not current_user.is_super_admin:
|
||||
raise InvalidCredentialsException(
|
||||
"Only super admins can deselect platform (platform admins must always have a platform)"
|
||||
)
|
||||
|
||||
# Issue new token without platform context
|
||||
auth_manager = AuthManager()
|
||||
token_data = auth_manager.create_access_token(user=current_user)
|
||||
|
||||
# Set cookie with new token
|
||||
response.set_cookie(
|
||||
key="admin_token",
|
||||
value=token_data["access_token"],
|
||||
httponly=True,
|
||||
secure=should_use_secure_cookies(),
|
||||
samesite="lax",
|
||||
max_age=token_data["expires_in"],
|
||||
path="/admin",
|
||||
)
|
||||
|
||||
logger.info(f"Super admin {current_user.username} deselected platform (global mode)")
|
||||
|
||||
return {
|
||||
"access_token": token_data["access_token"],
|
||||
"token_type": token_data["token_type"],
|
||||
"expires_in": token_data["expires_in"],
|
||||
}
|
||||
|
||||
@@ -20,9 +20,13 @@ from app.modules.tenancy.schemas import (
|
||||
MerchantPortalProfileResponse,
|
||||
MerchantPortalProfileUpdate,
|
||||
MerchantPortalStoreListResponse,
|
||||
MerchantStoreCreate,
|
||||
MerchantStoreDetailResponse,
|
||||
MerchantStoreUpdate,
|
||||
)
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
|
||||
|
||||
from .email_verification import email_verification_api_router
|
||||
from .merchant_auth import merchant_auth_router
|
||||
@@ -63,14 +67,113 @@ async def merchant_stores(
|
||||
db, merchant.id, skip=skip, limit=limit
|
||||
)
|
||||
|
||||
can_create, _ = merchant_store_service.can_create_store(db, merchant.id)
|
||||
|
||||
return MerchantPortalStoreListResponse(
|
||||
stores=stores,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
can_create_store=can_create,
|
||||
)
|
||||
|
||||
|
||||
@_account_router.post("/stores", response_model=MerchantStoreDetailResponse)
|
||||
async def create_merchant_store(
|
||||
store_data: MerchantStoreCreate,
|
||||
current_user: UserContext = Depends(get_current_merchant_api),
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new store under the merchant.
|
||||
|
||||
Checks subscription tier store limits before creation.
|
||||
New stores are created with is_active=True, is_verified=False.
|
||||
"""
|
||||
# Service raises MaxStoresReachedException, StoreAlreadyExistsException,
|
||||
# or StoreValidationException — all handled by global exception handler.
|
||||
result = merchant_store_service.create_store(
|
||||
db,
|
||||
merchant.id,
|
||||
store_data.model_dump(),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Merchant {merchant.id} ({current_user.username}) created store "
|
||||
f"'{store_data.store_code}'"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@_account_router.get("/stores/{store_id}", response_model=MerchantStoreDetailResponse)
|
||||
async def get_merchant_store_detail(
|
||||
store_id: int,
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get detailed store information with ownership validation.
|
||||
|
||||
Returns store details including platform assignments.
|
||||
"""
|
||||
# StoreNotFoundException handled by global exception handler
|
||||
return merchant_store_service.get_store_detail(db, merchant.id, store_id)
|
||||
|
||||
|
||||
@_account_router.put("/stores/{store_id}", response_model=MerchantStoreDetailResponse)
|
||||
async def update_merchant_store(
|
||||
store_id: int,
|
||||
update_data: MerchantStoreUpdate,
|
||||
current_user: UserContext = Depends(get_current_merchant_api),
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update store details (merchant-allowed fields only).
|
||||
|
||||
Only name, description, and contact info fields can be updated.
|
||||
"""
|
||||
# StoreNotFoundException handled by global exception handler
|
||||
result = merchant_store_service.update_store(
|
||||
db,
|
||||
merchant.id,
|
||||
store_id,
|
||||
update_data.model_dump(exclude_unset=True),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Merchant {merchant.id} ({current_user.username}) updated store {store_id}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@_account_router.get("/platforms")
|
||||
async def get_merchant_platforms(
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get platforms available for the merchant (from active subscriptions).
|
||||
|
||||
Used by the store creation/edit UI to show platform selection options.
|
||||
"""
|
||||
return merchant_store_service.get_subscribed_platform_ids(db, merchant.id)
|
||||
|
||||
|
||||
@_account_router.get("/team")
|
||||
async def merchant_team_overview(
|
||||
merchant=Depends(get_merchant_for_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get team members across all stores owned by the merchant.
|
||||
|
||||
Returns a list of stores with their team members grouped by store.
|
||||
"""
|
||||
return merchant_store_service.get_merchant_team_overview(db, merchant.id)
|
||||
|
||||
|
||||
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)
|
||||
async def merchant_profile(
|
||||
request: Request,
|
||||
|
||||
@@ -50,6 +50,55 @@ async def merchant_stores_page(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stores/{store_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def merchant_store_detail_page(
|
||||
request: Request,
|
||||
store_id: int,
|
||||
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render the merchant store detail/edit page.
|
||||
|
||||
Shows store details and allows editing merchant-allowed fields.
|
||||
"""
|
||||
context = get_context_for_frontend(
|
||||
FrontendType.MERCHANT,
|
||||
request,
|
||||
db,
|
||||
user=current_user,
|
||||
)
|
||||
context["store_id"] = store_id
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/merchant/store-detail.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/team", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def merchant_team_page(
|
||||
request: Request,
|
||||
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render the merchant team management page.
|
||||
|
||||
Shows team members across all stores owned by the merchant,
|
||||
with ability to invite and manage per-store teams.
|
||||
"""
|
||||
context = get_context_for_frontend(
|
||||
FrontendType.MERCHANT,
|
||||
request,
|
||||
db,
|
||||
user=current_user,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/merchant/team.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profile", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def merchant_profile_page(
|
||||
request: Request,
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.api.deps import (
|
||||
from app.modules.core.utils.page_context import get_store_context
|
||||
from app.modules.tenancy.models import User
|
||||
from app.templates_config import templates
|
||||
from app.utils.i18n import get_jinja2_globals
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -71,11 +72,13 @@ async def store_login_page(
|
||||
url=f"/store/{store_code}/dashboard", status_code=302
|
||||
)
|
||||
|
||||
language = getattr(request.state, "language", "fr")
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/store/login.html",
|
||||
{
|
||||
"request": request,
|
||||
"store_code": store_code,
|
||||
**get_jinja2_globals(language),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ from app.modules.tenancy.schemas.merchant import (
|
||||
MerchantPortalProfileUpdate,
|
||||
MerchantPortalStoreListResponse,
|
||||
MerchantResponse,
|
||||
MerchantStoreCreate,
|
||||
MerchantStoreDetailResponse,
|
||||
MerchantStoreUpdate,
|
||||
MerchantSummary,
|
||||
MerchantTransferOwnership,
|
||||
MerchantTransferOwnershipResponse,
|
||||
@@ -163,6 +166,9 @@ __all__ = [
|
||||
"MerchantPortalProfileUpdate",
|
||||
"MerchantPortalStoreListResponse",
|
||||
"MerchantResponse",
|
||||
"MerchantStoreCreate",
|
||||
"MerchantStoreDetailResponse",
|
||||
"MerchantStoreUpdate",
|
||||
"MerchantSummary",
|
||||
"MerchantTransferOwnership",
|
||||
"MerchantTransferOwnershipResponse",
|
||||
|
||||
@@ -261,3 +261,73 @@ class MerchantPortalStoreListResponse(BaseModel):
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
can_create_store: bool = True
|
||||
|
||||
|
||||
class MerchantStoreCreate(BaseModel):
|
||||
"""Store creation from the merchant portal.
|
||||
Subset of admin StoreCreate — excludes admin-only fields."""
|
||||
|
||||
name: str = Field(..., min_length=2, max_length=255, description="Store name")
|
||||
store_code: str = Field(
|
||||
..., min_length=2, max_length=50, description="Unique store code"
|
||||
)
|
||||
subdomain: str = Field(
|
||||
..., min_length=2, max_length=100, description="Store subdomain"
|
||||
)
|
||||
description: str | None = Field(None, description="Store description")
|
||||
platform_ids: list[int] = Field(
|
||||
default_factory=list, description="Platform IDs to assign store to"
|
||||
)
|
||||
|
||||
@field_validator("subdomain")
|
||||
@classmethod
|
||||
def validate_subdomain(cls, v):
|
||||
"""Validate subdomain format."""
|
||||
import re
|
||||
|
||||
v = v.lower().strip()
|
||||
if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", v) and len(v) > 1:
|
||||
raise ValueError(
|
||||
"Subdomain must contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("store_code")
|
||||
@classmethod
|
||||
def normalize_store_code(cls, v):
|
||||
"""Normalize store code to uppercase."""
|
||||
return v.upper().strip()
|
||||
|
||||
|
||||
class MerchantStoreDetailResponse(BaseModel):
|
||||
"""Store detail for the merchant portal."""
|
||||
|
||||
id: int
|
||||
store_code: str
|
||||
subdomain: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
contact_email: str | None = None
|
||||
contact_phone: str | None = None
|
||||
website: str | None = None
|
||||
business_address: str | None = None
|
||||
tax_number: str | None = None
|
||||
default_language: str | None = None
|
||||
created_at: str | None = None
|
||||
platforms: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MerchantStoreUpdate(BaseModel):
|
||||
"""Store update from the merchant portal.
|
||||
Only merchant-allowed fields."""
|
||||
|
||||
name: str | None = Field(None, min_length=2, max_length=255)
|
||||
description: str | None = None
|
||||
contact_email: EmailStr | None = None
|
||||
contact_phone: str | None = None
|
||||
website: str | None = None
|
||||
business_address: str | None = None
|
||||
tax_number: str | None = None
|
||||
|
||||
435
app/modules/tenancy/services/merchant_store_service.py
Normal file
435
app/modules/tenancy/services/merchant_store_service.py
Normal file
@@ -0,0 +1,435 @@
|
||||
# app/modules/tenancy/services/merchant_store_service.py
|
||||
"""
|
||||
Merchant store service for store CRUD operations from the merchant portal.
|
||||
|
||||
Handles store management operations that merchant owners can perform:
|
||||
- View store details (with ownership validation)
|
||||
- Update store settings (name, description, contact info)
|
||||
- Create new stores (with subscription limit checking)
|
||||
|
||||
Follows the service layer pattern — all DB operations go through here.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.tenancy.exceptions import (
|
||||
MerchantNotFoundException,
|
||||
StoreAlreadyExistsException,
|
||||
StoreNotFoundException,
|
||||
StoreValidationException,
|
||||
)
|
||||
from app.modules.tenancy.models.merchant import Merchant
|
||||
from app.modules.tenancy.models.platform import Platform
|
||||
from app.modules.tenancy.models.store import Role, Store
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MerchantStoreService:
|
||||
"""Service for merchant-initiated store operations."""
|
||||
|
||||
def get_store_detail(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
store_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Get store detail with ownership validation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID (for ownership check)
|
||||
store_id: Store ID
|
||||
|
||||
Returns:
|
||||
Dict with store details and platform assignments
|
||||
|
||||
Raises:
|
||||
StoreNotFoundException: If store not found or not owned by merchant
|
||||
"""
|
||||
store = (
|
||||
db.query(Store)
|
||||
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
|
||||
.first()
|
||||
)
|
||||
if not store:
|
||||
raise StoreNotFoundException(store_id, identifier_type="id")
|
||||
|
||||
# Get platform assignments
|
||||
store_platforms = (
|
||||
db.query(StorePlatform)
|
||||
.join(Platform, StorePlatform.platform_id == Platform.id)
|
||||
.filter(StorePlatform.store_id == store.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
platforms = []
|
||||
for sp in store_platforms:
|
||||
platform = db.query(Platform).filter(Platform.id == sp.platform_id).first()
|
||||
if platform:
|
||||
platforms.append(
|
||||
{
|
||||
"id": platform.id,
|
||||
"code": platform.code,
|
||||
"name": platform.name,
|
||||
"is_active": sp.is_active,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"id": store.id,
|
||||
"store_code": store.store_code,
|
||||
"subdomain": store.subdomain,
|
||||
"name": store.name,
|
||||
"description": store.description,
|
||||
"is_active": store.is_active,
|
||||
"is_verified": store.is_verified,
|
||||
"contact_email": store.contact_email,
|
||||
"contact_phone": store.contact_phone,
|
||||
"website": store.website,
|
||||
"business_address": store.business_address,
|
||||
"tax_number": store.tax_number,
|
||||
"default_language": store.default_language,
|
||||
"created_at": store.created_at.isoformat() if store.created_at else None,
|
||||
"platforms": platforms,
|
||||
}
|
||||
|
||||
def update_store(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
store_id: int,
|
||||
update_data: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Update store fields (merchant-allowed fields only).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID (for ownership check)
|
||||
store_id: Store ID
|
||||
update_data: Dict of fields to update
|
||||
|
||||
Returns:
|
||||
Updated store detail dict
|
||||
|
||||
Raises:
|
||||
StoreNotFoundException: If store not found or not owned by merchant
|
||||
"""
|
||||
store = (
|
||||
db.query(Store)
|
||||
.filter(Store.id == store_id, Store.merchant_id == merchant_id)
|
||||
.first()
|
||||
)
|
||||
if not store:
|
||||
raise StoreNotFoundException(store_id, identifier_type="id")
|
||||
|
||||
# Merchant-allowed update fields
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"description",
|
||||
"contact_email",
|
||||
"contact_phone",
|
||||
"website",
|
||||
"business_address",
|
||||
"tax_number",
|
||||
}
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field in allowed_fields:
|
||||
setattr(store, field, value)
|
||||
|
||||
db.flush()
|
||||
logger.info(
|
||||
f"Merchant {merchant_id} updated store {store.store_code}: "
|
||||
f"{list(update_data.keys())}"
|
||||
)
|
||||
|
||||
return self.get_store_detail(db, merchant_id, store_id)
|
||||
|
||||
def create_store(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
store_data: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a new store under the merchant.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
store_data: Store creation data (name, store_code, subdomain, description, platform_ids)
|
||||
|
||||
Returns:
|
||||
Created store detail dict
|
||||
|
||||
Raises:
|
||||
MaxStoresReachedException: If store limit reached
|
||||
MerchantNotFoundException: If merchant not found
|
||||
StoreAlreadyExistsException: If store code already exists
|
||||
StoreValidationException: If subdomain taken or validation fails
|
||||
"""
|
||||
# Check store creation limits
|
||||
can_create, message = self.can_create_store(db, merchant_id)
|
||||
if not can_create:
|
||||
from app.modules.tenancy.exceptions import MaxStoresReachedException
|
||||
|
||||
raise MaxStoresReachedException(max_stores=0)
|
||||
|
||||
# Validate merchant exists
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise MerchantNotFoundException(merchant_id, identifier_type="id")
|
||||
|
||||
store_code = store_data["store_code"].upper()
|
||||
subdomain = store_data["subdomain"].lower()
|
||||
|
||||
# Check store code uniqueness
|
||||
existing = (
|
||||
db.query(Store)
|
||||
.filter(func.upper(Store.store_code) == store_code)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise StoreAlreadyExistsException(store_code)
|
||||
|
||||
# Check subdomain uniqueness
|
||||
existing_sub = (
|
||||
db.query(Store)
|
||||
.filter(func.lower(Store.subdomain) == subdomain)
|
||||
.first()
|
||||
)
|
||||
if existing_sub:
|
||||
raise StoreValidationException(
|
||||
f"Subdomain '{subdomain}' is already taken",
|
||||
field="subdomain",
|
||||
)
|
||||
|
||||
try:
|
||||
# Create store
|
||||
store = Store(
|
||||
merchant_id=merchant_id,
|
||||
store_code=store_code,
|
||||
subdomain=subdomain,
|
||||
name=store_data["name"],
|
||||
description=store_data.get("description"),
|
||||
is_active=True,
|
||||
is_verified=False, # Pending admin verification
|
||||
)
|
||||
db.add(store)
|
||||
db.flush()
|
||||
|
||||
# Create default roles
|
||||
self._create_default_roles(db, store.id)
|
||||
|
||||
# Assign to platforms if provided
|
||||
platform_ids = store_data.get("platform_ids", [])
|
||||
for pid in platform_ids:
|
||||
platform = db.query(Platform).filter(Platform.id == pid).first()
|
||||
if platform:
|
||||
sp = StorePlatform(
|
||||
store_id=store.id,
|
||||
platform_id=pid,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp)
|
||||
|
||||
db.flush()
|
||||
db.refresh(store)
|
||||
|
||||
logger.info(
|
||||
f"Merchant {merchant_id} created store {store.store_code} "
|
||||
f"(ID: {store.id}, platforms: {platform_ids})"
|
||||
)
|
||||
|
||||
return self.get_store_detail(db, merchant_id, store.id)
|
||||
|
||||
except (
|
||||
StoreAlreadyExistsException,
|
||||
MerchantNotFoundException,
|
||||
StoreValidationException,
|
||||
):
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Failed to create store for merchant {merchant_id}: {e}")
|
||||
raise StoreValidationException(
|
||||
f"Failed to create store: {e}",
|
||||
field="store",
|
||||
)
|
||||
|
||||
def can_create_store(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if merchant can create a new store based on subscription limits.
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, message). message explains why if not allowed.
|
||||
"""
|
||||
try:
|
||||
from app.modules.billing.services.feature_service import feature_service
|
||||
|
||||
return feature_service.check_resource_limit(
|
||||
db,
|
||||
feature_code="stores_limit",
|
||||
merchant_id=merchant_id,
|
||||
)
|
||||
except Exception:
|
||||
# If billing module not available, allow creation
|
||||
return True, None
|
||||
|
||||
def get_subscribed_platform_ids(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get platforms the merchant has active subscriptions on.
|
||||
|
||||
Returns:
|
||||
List of platform dicts with id, code, name
|
||||
"""
|
||||
try:
|
||||
from app.modules.billing.services.subscription_service import (
|
||||
subscription_service,
|
||||
)
|
||||
|
||||
platform_ids = subscription_service.get_active_subscription_platform_ids(
|
||||
db, merchant_id
|
||||
)
|
||||
except Exception:
|
||||
platform_ids = []
|
||||
|
||||
platforms = []
|
||||
for pid in platform_ids:
|
||||
platform = db.query(Platform).filter(Platform.id == pid).first()
|
||||
if platform:
|
||||
platforms.append(
|
||||
{
|
||||
"id": platform.id,
|
||||
"code": platform.code,
|
||||
"name": platform.name,
|
||||
}
|
||||
)
|
||||
return platforms
|
||||
|
||||
def _create_default_roles(self, db: Session, store_id: int):
|
||||
"""Create default roles for a new store."""
|
||||
default_roles = [
|
||||
{"name": "Owner", "permissions": ["*"]},
|
||||
{
|
||||
"name": "Manager",
|
||||
"permissions": [
|
||||
"products.*",
|
||||
"orders.*",
|
||||
"customers.view",
|
||||
"inventory.*",
|
||||
"team.view",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Editor",
|
||||
"permissions": [
|
||||
"products.view",
|
||||
"products.edit",
|
||||
"orders.view",
|
||||
"inventory.view",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Viewer",
|
||||
"permissions": [
|
||||
"products.view",
|
||||
"orders.view",
|
||||
"customers.view",
|
||||
"inventory.view",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
roles = [
|
||||
Role(
|
||||
store_id=store_id,
|
||||
name=role_data["name"],
|
||||
permissions=role_data["permissions"],
|
||||
)
|
||||
for role_data in default_roles
|
||||
]
|
||||
db.add_all(roles)
|
||||
|
||||
|
||||
def get_merchant_team_overview(self, db: Session, merchant_id: int) -> dict:
|
||||
"""
|
||||
Get team members across all stores owned by the merchant.
|
||||
|
||||
Returns a list of stores with their team members grouped by store.
|
||||
"""
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise MerchantNotFoundException(merchant_id)
|
||||
|
||||
stores = (
|
||||
db.query(Store)
|
||||
.filter(Store.merchant_id == merchant_id)
|
||||
.order_by(Store.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for store in stores:
|
||||
members = (
|
||||
db.query(StoreUser)
|
||||
.filter(StoreUser.store_id == store.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
store_team = {
|
||||
"store_id": store.id,
|
||||
"store_name": store.name,
|
||||
"store_code": store.store_code,
|
||||
"is_active": store.is_active,
|
||||
"members": [
|
||||
{
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"email": m.user.email if m.user else None,
|
||||
"first_name": m.user.first_name if m.user else None,
|
||||
"last_name": m.user.last_name if m.user else None,
|
||||
"role_name": m.role.name if m.role else None,
|
||||
"is_active": m.is_active,
|
||||
"invitation_accepted_at": (
|
||||
m.invitation_accepted_at.isoformat()
|
||||
if m.invitation_accepted_at
|
||||
else None
|
||||
),
|
||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||
}
|
||||
for m in members
|
||||
],
|
||||
"member_count": len(members),
|
||||
}
|
||||
result.append(store_team)
|
||||
|
||||
return {
|
||||
"merchant_name": merchant.business_name or merchant.brand_name,
|
||||
"owner_email": merchant.owner.email if merchant.owner else None,
|
||||
"stores": result,
|
||||
"total_members": sum(s["member_count"] for s in result),
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
merchant_store_service = MerchantStoreService()
|
||||
|
||||
__all__ = ["MerchantStoreService", "merchant_store_service"]
|
||||
@@ -116,6 +116,8 @@ class PermissionDiscoveryService:
|
||||
# Settings (limited)
|
||||
"settings.view",
|
||||
"settings.theme",
|
||||
# Team (view only)
|
||||
"team.view",
|
||||
# Imports
|
||||
"imports.view",
|
||||
"imports.create",
|
||||
|
||||
@@ -26,8 +26,8 @@ class TenancyOnboardingProvider:
|
||||
return [
|
||||
OnboardingStepDefinition(
|
||||
key="tenancy.customize_store",
|
||||
title_key="onboarding.tenancy.customize_store.title",
|
||||
description_key="onboarding.tenancy.customize_store.description",
|
||||
title_key="tenancy.onboarding.customize_store.title",
|
||||
description_key="tenancy.onboarding.customize_store.description",
|
||||
icon="settings",
|
||||
route_template="/store/{store_code}/settings",
|
||||
order=100,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// static/admin/js/select-platform.js
|
||||
// Platform selection page for platform admins
|
||||
// Platform selection page for admins (platform admins and super admins)
|
||||
|
||||
const platformLog = window.LogConfig ? window.LogConfig.createLogger('PLATFORM_SELECT') : console;
|
||||
|
||||
@@ -11,6 +11,7 @@ function selectPlatform() {
|
||||
error: null,
|
||||
platforms: [],
|
||||
isSuperAdmin: false,
|
||||
currentPlatformId: null,
|
||||
|
||||
async init() {
|
||||
platformLog.info('=== PLATFORM SELECTION PAGE INITIALIZING ===');
|
||||
@@ -46,21 +47,14 @@ function selectPlatform() {
|
||||
const response = await apiClient.get('/admin/auth/accessible-platforms');
|
||||
platformLog.debug('Platforms response:', response);
|
||||
|
||||
this.isSuperAdmin = response.role === 'super_admin';
|
||||
this.isSuperAdmin = response.is_super_admin === true;
|
||||
this.platforms = response.platforms || [];
|
||||
this.currentPlatformId = response.current_platform_id || null;
|
||||
|
||||
if (this.isSuperAdmin) {
|
||||
platformLog.info('User is super admin, redirecting to dashboard...');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/dashboard';
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.requires_platform_selection && this.platforms.length === 1) {
|
||||
if (!this.isSuperAdmin && !response.requires_platform_selection && this.platforms.length === 1) {
|
||||
// Only one platform assigned, auto-select it
|
||||
platformLog.info('Single platform assigned, auto-selecting...');
|
||||
await this.selectPlatform(this.platforms[0]);
|
||||
await this.choosePlatform(this.platforms[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,7 +75,11 @@ function selectPlatform() {
|
||||
}
|
||||
},
|
||||
|
||||
async selectPlatform(platform) {
|
||||
isCurrentPlatform(platform) {
|
||||
return this.currentPlatformId && this.currentPlatformId === platform.id;
|
||||
},
|
||||
|
||||
async choosePlatform(platform) {
|
||||
if (this.selecting) return;
|
||||
|
||||
this.selecting = true;
|
||||
@@ -132,6 +130,37 @@ function selectPlatform() {
|
||||
}
|
||||
},
|
||||
|
||||
async deselectPlatform() {
|
||||
if (this.selecting) return;
|
||||
|
||||
this.selecting = true;
|
||||
this.error = null;
|
||||
platformLog.info('Deselecting platform (returning to global mode)...');
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/admin/auth/deselect-platform');
|
||||
|
||||
if (response.access_token) {
|
||||
// Store new token without platform context
|
||||
localStorage.setItem('admin_token', response.access_token);
|
||||
localStorage.setItem('token', response.access_token);
|
||||
|
||||
// Remove platform info
|
||||
localStorage.removeItem('admin_platform');
|
||||
|
||||
platformLog.info('Platform deselected, redirecting to dashboard...');
|
||||
window.location.href = '/admin/dashboard';
|
||||
} else {
|
||||
throw new Error('No token received from server');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
platformLog.error('Platform deselection failed:', error);
|
||||
this.error = error.message || 'Failed to deselect platform. Please try again.';
|
||||
this.selecting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
platformLog.info('Logging out...');
|
||||
|
||||
|
||||
@@ -8,6 +8,27 @@
|
||||
// Create custom logger for store login page
|
||||
const storeLoginLog = window.LogConfig.createLogger('STORE-LOGIN');
|
||||
|
||||
function languageSelector(currentLang, enabledLanguages) {
|
||||
return {
|
||||
currentLang: currentLang || 'fr',
|
||||
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
||||
async setLanguage(lang) {
|
||||
if (lang === this.currentLang) return;
|
||||
try {
|
||||
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
|
||||
await fetch('/api/v1/platform/language/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
storeLoginLog.error('Failed to set language:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function storeLogin() {
|
||||
return {
|
||||
credentials: {
|
||||
@@ -159,6 +180,7 @@ function storeLogin() {
|
||||
},
|
||||
|
||||
// Forgot password state
|
||||
rememberMe: false,
|
||||
showForgotPassword: false,
|
||||
forgotPasswordEmail: '',
|
||||
forgotPasswordLoading: false,
|
||||
|
||||
@@ -39,7 +39,6 @@ function storeSettings() {
|
||||
{ id: 'invoices', label: 'Invoices', icon: 'document-text' },
|
||||
{ id: 'branding', label: 'Branding', icon: 'color-swatch' },
|
||||
{ id: 'domains', label: 'Domains', icon: 'globe-alt' },
|
||||
{ id: 'api', label: 'API & Payments', icon: 'key' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'bell' },
|
||||
{ id: 'email', label: 'Email', icon: 'mail' }
|
||||
],
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Admin Login
|
||||
{{ _("auth.admin_login") }}
|
||||
</h1>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
@@ -45,80 +45,129 @@
|
||||
x-transition></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Username</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.username") }}</span>
|
||||
<input x-model="credentials.username"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.username }"
|
||||
placeholder="Enter your username"
|
||||
placeholder="{{ _('auth.username_placeholder') }}"
|
||||
autocomplete="username"
|
||||
required />
|
||||
<span x-show="errors.username" x-text="errors.username"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="***************"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
|
||||
<div class="relative" x-data="{ showPw: false }">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="{{ _('auth.password_placeholder') }}"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPw = !showPw"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPw ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
|
||||
</label>
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
{{ _("auth.forgot_password") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# noqa: FE-002 - Inline spinner SVG for loading state #}
|
||||
<button type="submit" :disabled="loading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
{{ _("auth.signing_in") }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div x-show="showForgotPassword" x-cloak x-transition class="mt-6">
|
||||
<hr class="mb-6" />
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
|
||||
<form @submit.prevent="handleForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="forgotPasswordEmail"
|
||||
:disabled="forgotPasswordLoading"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
placeholder="{{ _('auth.email_placeholder') }}"
|
||||
type="email"
|
||||
required />
|
||||
</label>
|
||||
<button type="submit" :disabled="forgotPasswordLoading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
|
||||
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = false"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
← {{ _("auth.back_to_login") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4">
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
||||
href="#">
|
||||
Forgot your password?
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||
href="/">
|
||||
{{ _("auth.visit_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← Back to Platform
|
||||
← {{ _("auth.back_to_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
|
||||
async setLang(lang) {
|
||||
await fetch('/api/v1/platform/language/set', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({language: lang})
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
}">
|
||||
<button @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
|
||||
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
|
||||
<button @click="setLang('de')" class="fi fi-de text-lg opacity-60 hover:opacity-100 transition-opacity" title="Deutsch"></button>
|
||||
<button @click="setLang('lb')" class="fi fi-lu text-lg opacity-60 hover:opacity-100 transition-opacity" title="Lëtzebuergesch"></button>
|
||||
<div class="flex items-center justify-center gap-2 mt-6"
|
||||
x-data='languageSelector("{{ request.state.language|default("en") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
x-text="lang.toUpperCase()"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure which menu items are visible for admins, stores, and merchants on this platform.
|
||||
Configure which menu items are visible for admins and stores on this platform.
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" x-text="platform?.code?.toUpperCase()"></span>
|
||||
@@ -52,17 +52,8 @@
|
||||
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
|
||||
Store Frontend
|
||||
</button>
|
||||
<button
|
||||
@click="frontendType = 'merchant'; loadPlatformMenuConfig()"
|
||||
:class="{
|
||||
'bg-white dark:bg-gray-800 shadow': frontendType === 'merchant',
|
||||
'text-gray-600 dark:text-gray-400': frontendType !== 'merchant'
|
||||
}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md transition-all"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-4 h-4 inline mr-2')"></span>
|
||||
Merchant Frontend
|
||||
</button>
|
||||
{# Merchant frontend menu is driven by module enablement + subscriptions,
|
||||
not by AdminMenuConfig visibility. No tab needed here. #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,20 +37,44 @@
|
||||
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Super Admin Notice -->
|
||||
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p class="text-blue-700 dark:text-blue-400">
|
||||
You are a Super Admin with access to all platforms. Redirecting to dashboard...
|
||||
</p>
|
||||
<!-- Global Mode Card (Super Admins Only) -->
|
||||
<div x-show="isSuperAdmin && !loading" x-cloak class="mb-3">
|
||||
<button
|
||||
@click="deselectPlatform()"
|
||||
:disabled="selecting"
|
||||
class="w-full flex items-center p-4 rounded-lg border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="!currentPlatformId
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-500 ring-2 ring-green-200 dark:ring-green-800'
|
||||
: 'bg-gray-50 dark:bg-gray-700 border-transparent hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20'"
|
||||
>
|
||||
<!-- Globe Icon -->
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center mr-4">
|
||||
<span x-html="$icon('globe-alt', 'w-6 h-6 text-green-600 dark:text-green-400')"></span>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200">Global Mode (All Platforms)</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Access all modules across all platforms</p>
|
||||
</div>
|
||||
|
||||
<!-- Checkmark for active -->
|
||||
<div class="flex-shrink-0 ml-4" x-show="!currentPlatformId">
|
||||
<span x-html="$icon('check-circle', 'w-6 h-6 text-green-600 dark:text-green-400')"></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Platform List -->
|
||||
<div x-show="!loading && !isSuperAdmin && platforms.length > 0" x-cloak class="space-y-3">
|
||||
<div x-show="!loading && platforms.length > 0" x-cloak class="space-y-3">
|
||||
<template x-for="platform in platforms" :key="platform.id">
|
||||
<button
|
||||
@click="selectPlatform(platform)"
|
||||
@click="choosePlatform(platform)"
|
||||
:disabled="selecting"
|
||||
class="w-full flex items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-transparent hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="w-full flex items-center p-4 rounded-lg border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="isCurrentPlatform(platform)
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-500 ring-2 ring-purple-200 dark:ring-purple-800'
|
||||
: 'bg-gray-50 dark:bg-gray-700 border-transparent hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20'"
|
||||
>
|
||||
<!-- Platform Icon/Logo -->
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center mr-4">
|
||||
@@ -68,9 +92,14 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="platform.code"></p>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<!-- Checkmark for active / Arrow for others -->
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
<template x-if="isCurrentPlatform(platform)">
|
||||
<span x-html="$icon('check-circle', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
|
||||
</template>
|
||||
<template x-if="!isCurrentPlatform(platform)">
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -93,8 +122,41 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<div class="mt-4 flex justify-center">
|
||||
<!-- Language & Theme Toggle -->
|
||||
<div class="mt-4 flex justify-center items-center gap-2">
|
||||
<!-- Language selector -->
|
||||
<div class="relative" x-data="{ langOpen: false, currentLang: '{{ request.state.language|default('en') }}', async setLang(lang) { this.currentLang = lang; await fetch('/api/v1/platform/language/set', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({language: lang}) }); window.location.reload(); } }">
|
||||
<button
|
||||
@click="langOpen = !langOpen"
|
||||
@click.outside="langOpen = false"
|
||||
class="inline-flex items-center gap-1 p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span x-html="$icon('globe-alt', 'w-5 h-5')"></span>
|
||||
<span class="text-xs font-semibold uppercase" x-text="currentLang"></span>
|
||||
</button>
|
||||
<div
|
||||
x-show="langOpen"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 w-40 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-100 dark:border-gray-600 py-1 z-50"
|
||||
>
|
||||
<button @click="setLang('en'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">EN</span> English
|
||||
</button>
|
||||
<button @click="setLang('fr'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">FR</span> Français
|
||||
</button>
|
||||
<button @click="setLang('de'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">DE</span> Deutsch
|
||||
</button>
|
||||
<button @click="setLang('lb'); langOpen = false" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-semibold text-xs w-5">LB</span> Lëtzebuergesch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
@click="toggleDarkMode()"
|
||||
class="p-2 text-gray-500 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Merchant Login
|
||||
{{ _("auth.merchant_login") }}
|
||||
</h1>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
@@ -46,110 +46,129 @@
|
||||
x-transition></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="credentials.email"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="you@example.com"
|
||||
placeholder="{{ _('auth.email_placeholder') }}"
|
||||
autocomplete="username"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="***************"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
|
||||
<div class="relative" x-data="{ showPw: false }">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="{{ _('auth.password_placeholder') }}"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPw = !showPw"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPw ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
|
||||
</label>
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
{{ _("auth.forgot_password") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# noqa: FE-002 - Inline spinner SVG for loading state #}
|
||||
<button type="submit" :disabled="loading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
{{ _("auth.signing_in") }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div x-show="showForgotPassword" x-transition>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
<div x-show="showForgotPassword" x-cloak x-transition class="mt-6">
|
||||
<hr class="mb-6" />
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
|
||||
<form @submit.prevent="handleForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="forgotPasswordEmail"
|
||||
:disabled="forgotPasswordLoading"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
placeholder="you@example.com"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
placeholder="{{ _('auth.email_placeholder') }}"
|
||||
type="email"
|
||||
required />
|
||||
</label>
|
||||
<button type="submit" :disabled="forgotPasswordLoading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
|
||||
<span x-show="forgotPasswordLoading">Sending...</span>
|
||||
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
|
||||
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = false"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
← Back to Login
|
||||
← {{ _("auth.back_to_login") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!showForgotPassword">
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← Back to Platform
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||
href="/">
|
||||
{{ _("auth.visit_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← {{ _("auth.back_to_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center justify-center gap-3 mt-6" x-data="{
|
||||
async setLang(lang) {
|
||||
await fetch('/api/v1/platform/language/set', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({language: lang})
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
}">
|
||||
<button @click="setLang('en')" class="fi fi-gb text-lg opacity-60 hover:opacity-100 transition-opacity" title="English"></button>
|
||||
<button @click="setLang('fr')" class="fi fi-fr text-lg opacity-60 hover:opacity-100 transition-opacity" title="Français"></button>
|
||||
<button @click="setLang('de')" class="fi fi-de text-lg opacity-60 hover:opacity-100 transition-opacity" title="Deutsch"></button>
|
||||
<button @click="setLang('lb')" class="fi fi-lu text-lg opacity-60 hover:opacity-100 transition-opacity" title="Lëtzebuergesch"></button>
|
||||
<div class="flex items-center justify-center gap-2 mt-6"
|
||||
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
x-text="lang.toUpperCase()"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
315
app/modules/tenancy/templates/tenancy/merchant/store-detail.html
Normal file
315
app/modules/tenancy/templates/tenancy/merchant/store-detail.html
Normal file
@@ -0,0 +1,315 @@
|
||||
{# app/modules/tenancy/templates/tenancy/merchant/store-detail.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
|
||||
{% block title %}Store Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="merchantStoreDetail({{ store_id }})">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<a href="/merchants/account/stores" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Stores
|
||||
</a>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900" x-text="store?.name || 'Store Details'"></h2>
|
||||
<p class="mt-1 text-sm text-gray-400 font-mono" x-text="store?.store_code"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Status Badges -->
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': store?.is_active,
|
||||
'bg-gray-100 text-gray-600': !store?.is_active
|
||||
}"
|
||||
x-text="store?.is_active ? 'Active' : 'Inactive'"></span>
|
||||
<template x-if="store?.is_verified">
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">Verified</span>
|
||||
</template>
|
||||
<template x-if="store && !store.is_verified">
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Pending Verification</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-800" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-sm text-green-800" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading store details...
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && store" class="space-y-6">
|
||||
|
||||
<!-- Store Information (read-only) -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Store Information</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Store Code</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono" x-text="store.store_code"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Subdomain</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.subdomain"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Default Language</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.default_language || 'Not set'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="formatDate(store.created_at)"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Assignments -->
|
||||
<div x-show="store.platforms && store.platforms.length > 0" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Platforms</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="platform in store.platforms" :key="platform.id">
|
||||
<span class="inline-flex items-center px-3 py-1.5 text-sm rounded-lg border"
|
||||
:class="{
|
||||
'bg-green-50 border-green-200 text-green-800': platform.is_active,
|
||||
'bg-gray-50 border-gray-200 text-gray-600': !platform.is_active
|
||||
}">
|
||||
<span x-text="platform.name"></span>
|
||||
<span class="ml-1.5 text-xs font-mono opacity-60" x-text="platform.code"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editable Fields -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Store Details</h3>
|
||||
<template x-if="!editing">
|
||||
<button @click="startEditing()" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Edit</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- View Mode -->
|
||||
<div x-show="!editing" class="p-6">
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.name"></dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Description</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.description || 'No description'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Contact Email</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.contact_email || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Contact Phone</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.contact_phone || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Website</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.website || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Tax Number</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.tax_number || '-'"></dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Business Address</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900" x-text="store.business_address || '-'"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<form x-show="editing" @submit.prevent="saveStore()" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" x-model="editForm.name" required minlength="2" maxlength="255"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea x-model="editForm.description" rows="2"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
|
||||
<input type="email" x-model="editForm.contact_email"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Phone</label>
|
||||
<input type="tel" x-model="editForm.contact_phone"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Website</label>
|
||||
<input type="url" x-model="editForm.website" placeholder="https://example.com"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Business Address</label>
|
||||
<textarea x-model="editForm.business_address" rows="2"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Tax Number (VAT ID)</label>
|
||||
<input type="text" x-model="editForm.tax_number" placeholder="LU12345678"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" @click="editing = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="saving"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
|
||||
<span x-show="!saving">Save Changes</span>
|
||||
<span x-show="saving" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div x-show="!loading && !store && notFound" class="text-center py-16">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-700">Store not found</h3>
|
||||
<p class="mt-1 text-gray-500">This store doesn't exist or you don't have access to it.</p>
|
||||
<a href="/merchants/account/stores" class="mt-4 inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||
← Back to Stores
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function merchantStoreDetail(storeId) {
|
||||
return {
|
||||
storeId,
|
||||
loading: true,
|
||||
store: null,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
notFound: false,
|
||||
editing: false,
|
||||
saving: false,
|
||||
editForm: {},
|
||||
|
||||
init() {
|
||||
this.loadStore();
|
||||
},
|
||||
|
||||
async loadStore() {
|
||||
try {
|
||||
this.store = await apiClient.get(`/merchants/account/stores/${this.storeId}`);
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
this.notFound = true;
|
||||
} else {
|
||||
this.error = 'Failed to load store details.';
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
startEditing() {
|
||||
this.editForm = {
|
||||
name: this.store.name || '',
|
||||
description: this.store.description || '',
|
||||
contact_email: this.store.contact_email || '',
|
||||
contact_phone: this.store.contact_phone || '',
|
||||
website: this.store.website || '',
|
||||
business_address: this.store.business_address || '',
|
||||
tax_number: this.store.tax_number || '',
|
||||
};
|
||||
this.editing = true;
|
||||
},
|
||||
|
||||
async saveStore() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
// Only send fields that changed
|
||||
const payload = {};
|
||||
for (const [key, value] of Object.entries(this.editForm)) {
|
||||
const original = this.store[key] || '';
|
||||
if (value !== original) {
|
||||
payload[key] = value || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
this.editing = false;
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.store = await apiClient.put(
|
||||
`/merchants/account/stores/${this.storeId}`,
|
||||
payload
|
||||
);
|
||||
this.editing = false;
|
||||
this.successMessage = 'Store updated successfully.';
|
||||
setTimeout(() => { this.successMessage = null; }, 3000);
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to update store.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -7,9 +7,21 @@
|
||||
<div x-data="merchantStores()">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900">My Stores</h2>
|
||||
<p class="mt-1 text-gray-500">View and manage your connected stores.</p>
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">My Stores</h2>
|
||||
<p class="mt-1 text-gray-500">View and manage your connected stores.</p>
|
||||
</div>
|
||||
<button
|
||||
x-show="canCreateStore"
|
||||
@click="showCreateModal = true"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Add Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@@ -17,6 +29,11 @@
|
||||
<p class="text-sm text-red-800" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-sm text-green-800" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
@@ -33,50 +50,195 @@
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-700">No stores yet</h3>
|
||||
<p class="mt-1 text-gray-500">Your stores will appear here once connected.</p>
|
||||
<p class="mt-1 text-gray-500">Create your first store to get started.</p>
|
||||
<button
|
||||
x-show="canCreateStore"
|
||||
@click="showCreateModal = true"
|
||||
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-semibold text-indigo-600 border border-indigo-300 rounded-lg hover:bg-indigo-50 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Add Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Store Cards Grid -->
|
||||
<div x-show="!loading && stores.length > 0" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="store in stores" :key="store.id">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
|
||||
<a :href="'/merchants/account/stores/' + store.id"
|
||||
class="block bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md hover:border-indigo-200 transition-all cursor-pointer group">
|
||||
<div class="p-6">
|
||||
<!-- Store Name and Status -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="store.name"></h3>
|
||||
<h3 class="text-lg font-semibold text-gray-900 group-hover:text-indigo-600 transition-colors" x-text="store.name"></h3>
|
||||
<p class="text-sm text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': store.status === 'active',
|
||||
'bg-yellow-100 text-yellow-800': store.status === 'pending',
|
||||
'bg-gray-100 text-gray-600': store.status === 'inactive',
|
||||
'bg-red-100 text-red-800': store.status === 'suspended'
|
||||
'bg-green-100 text-green-800': store.is_active,
|
||||
'bg-gray-100 text-gray-600': !store.is_active
|
||||
}"
|
||||
x-text="(store.status || 'active').toUpperCase()"></span>
|
||||
x-text="store.is_active ? 'ACTIVE' : 'INACTIVE'"></span>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Store Code</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="store.store_code"></dd>
|
||||
<dt class="text-gray-500">Subdomain</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="store.subdomain || '-'"></dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Created</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="formatDate(store.created_at)"></dd>
|
||||
</div>
|
||||
<div x-show="store.platform_name" class="flex justify-between">
|
||||
<dt class="text-gray-500">Platform</dt>
|
||||
<dd class="font-medium text-gray-900" x-text="store.platform_name"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Verification Badge -->
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<template x-if="store.is_verified">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Verified
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!store.is_verified">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-yellow-100 text-yellow-800">
|
||||
Pending Verification
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card Footer -->
|
||||
<div class="px-6 py-3 bg-gray-50 border-t border-gray-100 text-right">
|
||||
<span class="text-xs text-indigo-600 font-medium group-hover:underline">View Details →</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Create Store Modal -->
|
||||
<div x-show="showCreateModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50" @click="showCreateModal = false"></div>
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-4" @click.stop>
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Create New Store</h3>
|
||||
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createStore()" class="p-6 space-y-4">
|
||||
<!-- Store Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Store Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="createForm.name"
|
||||
@input="autoGenerateCode()"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="255"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="My Awesome Store"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Store Code & Subdomain -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Store Code</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="createForm.store_code"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 font-mono uppercase focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="MYSTORE"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Subdomain</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="createForm.subdomain"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="100"
|
||||
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 lowercase focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="my-store"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
x-model="createForm.description"
|
||||
rows="2"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="Brief description of the store"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Platform Selection -->
|
||||
<div x-show="availablePlatforms.length > 0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Platforms</label>
|
||||
<div class="space-y-2">
|
||||
<template x-for="platform in availablePlatforms" :key="platform.id">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="platform.id"
|
||||
x-model.number="createForm.platform_ids"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700" x-text="platform.name"></span>
|
||||
<span class="text-xs text-gray-400 font-mono" x-text="platform.code"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Error -->
|
||||
<div x-show="createError" class="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-800" x-text="createError"></p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating"
|
||||
class="inline-flex items-center px-5 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!creating">Create Store</span>
|
||||
<span x-show="creating" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Creating...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -86,7 +248,20 @@ function merchantStores() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
stores: [],
|
||||
canCreateStore: false,
|
||||
showCreateModal: false,
|
||||
creating: false,
|
||||
createError: null,
|
||||
availablePlatforms: [],
|
||||
createForm: {
|
||||
name: '',
|
||||
store_code: '',
|
||||
subdomain: '',
|
||||
description: '',
|
||||
platform_ids: [],
|
||||
},
|
||||
|
||||
init() {
|
||||
this.loadStores();
|
||||
@@ -96,6 +271,7 @@ function merchantStores() {
|
||||
try {
|
||||
const data = await apiClient.get('/merchants/account/stores');
|
||||
this.stores = data.stores || data.items || [];
|
||||
this.canCreateStore = data.can_create_store !== false;
|
||||
} catch (err) {
|
||||
console.error('Error loading stores:', err);
|
||||
this.error = 'Failed to load stores. Please try again.';
|
||||
@@ -104,6 +280,45 @@ function merchantStores() {
|
||||
}
|
||||
},
|
||||
|
||||
autoGenerateCode() {
|
||||
const name = this.createForm.name;
|
||||
this.createForm.store_code = name
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 20);
|
||||
this.createForm.subdomain = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
},
|
||||
|
||||
async createStore() {
|
||||
this.creating = true;
|
||||
this.createError = null;
|
||||
|
||||
// Load platforms if not yet loaded
|
||||
if (this.availablePlatforms.length === 0) {
|
||||
try {
|
||||
this.availablePlatforms = await apiClient.get('/merchants/account/platforms');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post('/merchants/account/stores', this.createForm);
|
||||
this.showCreateModal = false;
|
||||
this.createForm = { name: '', store_code: '', subdomain: '', description: '', platform_ids: [] };
|
||||
this.successMessage = 'Store created successfully! It is pending admin verification.';
|
||||
setTimeout(() => { this.successMessage = null; }, 5000);
|
||||
await this.loadStores();
|
||||
} catch (err) {
|
||||
this.createError = err.message || 'Failed to create store.';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
140
app/modules/tenancy/templates/tenancy/merchant/team.html
Normal file
140
app/modules/tenancy/templates/tenancy/merchant/team.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{# app/modules/tenancy/templates/tenancy/merchant/team.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
|
||||
{% block title %}{{ _("tenancy.team.title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="merchantTeam()">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("tenancy.team.title") }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("tenancy.team.members") }}
|
||||
<span x-show="data" class="font-medium" x-text="`(${data?.total_members || 0})`"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ _("common.loading") }}
|
||||
</div>
|
||||
|
||||
<!-- Store Teams -->
|
||||
<div x-show="!loading && data" x-cloak class="space-y-6">
|
||||
<template x-for="store in data?.stores || []" :key="store.store_id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- Store Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span x-html="$icon('shopping-bag', 'w-5 h-5 text-gray-400')"></span>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="store.store_name"></h3>
|
||||
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="store.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="store.is_active ? '{{ _("common.active") }}' : '{{ _("common.inactive") }}'">
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="`${store.member_count} {{ _("tenancy.team.members").toLowerCase() }}`"></span>
|
||||
<a :href="`/store/${store.store_code}/team`"
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors">
|
||||
<span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
|
||||
{{ _("common.view") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<!-- Owner Row -->
|
||||
<div class="px-6 py-3 flex items-center gap-4 bg-gray-50/50 dark:bg-gray-700/30">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center flex-shrink-0">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4 text-indigo-600 dark:text-indigo-400')"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="data?.owner_email || '{{ _("tenancy.team.owner") }}'"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 rounded-full">{{ _("tenancy.team.owner") }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Team Members -->
|
||||
<template x-for="member in store.members" :key="member.id">
|
||||
<div class="px-6 py-3 flex items-center gap-4">
|
||||
<div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<span x-html="$icon('user', 'w-4 h-4 text-gray-500 dark:text-gray-400')"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<span x-text="member.first_name || ''"></span>
|
||||
<span x-text="member.last_name || ''"></span>
|
||||
<span x-show="!member.first_name && !member.last_name" x-text="member.email"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="member.email"
|
||||
x-show="member.first_name || member.last_name"></p>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="member.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'"
|
||||
x-text="member.is_active ? (member.role_name || '{{ _("tenancy.team.members") }}') : '{{ _("common.pending") }}'">
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="store.members.length === 0">
|
||||
<div class="px-6 py-6 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ _("tenancy.team.title") }} - {{ _("common.none") }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State: No Stores -->
|
||||
<template x-if="data && data.stores.length === 0">
|
||||
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<span x-html="$icon('user-group', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ _("common.not_available") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function merchantTeam() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
data: null,
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.data = await apiClient.get('/merchants/tenancy/account/team');
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Failed to load team data';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Store Portal Login
|
||||
{{ _("auth.store_login") }}
|
||||
</h1>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
@@ -54,42 +54,63 @@
|
||||
|
||||
<!-- Login Form (only show if store found) -->
|
||||
<template x-if="store">
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleLogin" x-show="!showForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Username</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.username") }}</span>
|
||||
<input x-model="credentials.username"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.username }"
|
||||
placeholder="Enter your username"
|
||||
placeholder="{{ _('auth.username_placeholder') }}"
|
||||
autocomplete="username"
|
||||
required />
|
||||
<span x-show="errors.username" x-text="errors.username"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="***************"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
|
||||
<div class="relative" x-data="{ showPw: false }">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="{{ _('auth.password_placeholder') }}"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPw = !showPw"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPw ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox text-purple-600 border-gray-300 rounded focus:ring-purple-500 focus:outline-none">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
|
||||
</label>
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
{{ _("auth.forgot_password") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Signing in...
|
||||
{{ _("auth.signing_in") }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
@@ -117,49 +138,64 @@
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading store information...</p>
|
||||
</div>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<div x-show="showForgotPassword" x-transition>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Reset Password</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
<div x-show="store && showForgotPassword" x-cloak x-transition class="mt-6">
|
||||
<hr class="mb-6" />
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">{{ _("auth.reset_password") }}</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">{{ _("auth.reset_password_desc") }}</p>
|
||||
<form @submit.prevent="handleForgotPassword">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
|
||||
<input x-model="forgotPasswordEmail"
|
||||
:disabled="forgotPasswordLoading"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input rounded-md border-gray-300"
|
||||
placeholder="you@example.com"
|
||||
type="email"
|
||||
required />
|
||||
</label>
|
||||
<button type="submit" :disabled="forgotPasswordLoading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!forgotPasswordLoading">Send Reset Link</span>
|
||||
<span x-show="forgotPasswordLoading">Sending...</span>
|
||||
<span x-show="!forgotPasswordLoading">{{ _("auth.send_reset_link") }}</span>
|
||||
<span x-show="forgotPasswordLoading">{{ _("auth.sending") }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = false"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
← Back to Login
|
||||
← {{ _("auth.back_to_login") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!showForgotPassword">
|
||||
<p class="mt-4">
|
||||
<a @click.prevent="showForgotPassword = true"
|
||||
class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline cursor-pointer">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← Back to Platform
|
||||
</a>
|
||||
</p>
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline ml-1"
|
||||
href="/">
|
||||
{{ _("auth.visit_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← {{ _("auth.back_to_platform") }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center justify-center gap-2 mt-6"
|
||||
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<button
|
||||
@click="setLanguage(lang)"
|
||||
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
|
||||
:class="currentLang === lang
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
|
||||
x-text="lang.toUpperCase()"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
{{ tab_button('branding', 'Branding', tab_var='activeSection', icon='color-swatch') }}
|
||||
{{ tab_button('email', 'Email', tab_var='activeSection', icon='mail') }}
|
||||
{{ tab_button('domains', 'Domains', tab_var='activeSection', icon='globe-alt') }}
|
||||
{{ tab_button('api', 'API', tab_var='activeSection', icon='key') }}
|
||||
{{ tab_button('notifications', 'Notifications', tab_var='activeSection', icon='bell') }}
|
||||
{% endcall %}
|
||||
|
||||
@@ -1274,79 +1273,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API & Payments Settings -->
|
||||
<div x-show="activeSection === 'api'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">API & Payments</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Payment integrations and API access</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-6">
|
||||
<!-- Stripe Integration -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('credit-card', 'w-6 h-6 text-purple-600 dark:text-purple-400')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Stripe</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Payment processing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="settings?.stripe_info?.has_stripe_customer">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">Connected</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Customer ID</label>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="settings?.stripe_info?.customer_id_masked"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!settings?.stripe_info?.has_stripe_customer">
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5 text-gray-400 dark:text-gray-500')"></span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Not connected</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Letzshop API (if credentials exist) -->
|
||||
<template x-if="settings?.letzshop?.has_credentials">
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-orange-100 dark:bg-orange-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('shopping-cart', 'w-6 h-6 text-orange-600 dark:text-orange-400')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Letzshop API</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Marketplace integration</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">Credentials configured</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-start gap-2">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5')"></span>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
API keys and payment credentials are managed securely. Contact support for changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<div x-show="activeSection === 'notifications'" class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
|
||||
255
app/modules/tenancy/tests/unit/test_merchant_store_service.py
Normal file
255
app/modules/tenancy/tests/unit/test_merchant_store_service.py
Normal file
@@ -0,0 +1,255 @@
|
||||
# tests/unit/test_merchant_store_service.py
|
||||
"""Unit tests for MerchantStoreService."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.exceptions import (
|
||||
MerchantNotFoundException,
|
||||
StoreAlreadyExistsException,
|
||||
StoreNotFoundException,
|
||||
StoreValidationException,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
from app.modules.tenancy.services.merchant_store_service import MerchantStoreService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def merchant_owner(db, test_user):
|
||||
"""Create a merchant owned by test_user."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
merchant = Merchant(
|
||||
name=f"Test Merchant {unique_id}",
|
||||
owner_user_id=test_user.id,
|
||||
contact_email=f"merchant{unique_id}@test.com",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_merchant_owner(db):
|
||||
"""Create a separate merchant for ownership isolation tests."""
|
||||
from app.modules.tenancy.models.user import User
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
user = User(
|
||||
email=f"other{unique_id}@test.com",
|
||||
username=f"other_{unique_id}",
|
||||
hashed_password="fakehash", # noqa: SEC001
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Other Merchant {unique_id}",
|
||||
owner_user_id=user.id,
|
||||
contact_email=f"otherm{unique_id}@test.com",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def merchant_store(db, merchant_owner):
|
||||
"""Create a store under the merchant."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
store = Store(
|
||||
merchant_id=merchant_owner.id,
|
||||
store_code=f"MS_{unique_id}".upper(),
|
||||
subdomain=f"ms-{unique_id}".lower(),
|
||||
name=f"Merchant Store {unique_id}",
|
||||
description="A test store",
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantStoreServiceGetDetail:
|
||||
"""Tests for get_store_detail."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MerchantStoreService()
|
||||
|
||||
def test_get_store_detail_success(self, db, merchant_owner, merchant_store):
|
||||
"""Test getting store detail with valid ownership."""
|
||||
result = self.service.get_store_detail(db, merchant_owner.id, merchant_store.id)
|
||||
|
||||
assert result["id"] == merchant_store.id
|
||||
assert result["store_code"] == merchant_store.store_code
|
||||
assert result["name"] == merchant_store.name
|
||||
assert result["is_active"] is True
|
||||
assert "platforms" in result
|
||||
|
||||
def test_get_store_detail_wrong_merchant(
|
||||
self, db, other_merchant_owner, merchant_store
|
||||
):
|
||||
"""Test that accessing another merchant's store raises not found."""
|
||||
with pytest.raises(StoreNotFoundException):
|
||||
self.service.get_store_detail(
|
||||
db, other_merchant_owner.id, merchant_store.id
|
||||
)
|
||||
|
||||
def test_get_store_detail_nonexistent(self, db, merchant_owner):
|
||||
"""Test getting a non-existent store."""
|
||||
with pytest.raises(StoreNotFoundException):
|
||||
self.service.get_store_detail(db, merchant_owner.id, 99999)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantStoreServiceUpdate:
|
||||
"""Tests for update_store."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MerchantStoreService()
|
||||
|
||||
def test_update_store_success(self, db, merchant_owner, merchant_store):
|
||||
"""Test updating store fields."""
|
||||
result = self.service.update_store(
|
||||
db,
|
||||
merchant_owner.id,
|
||||
merchant_store.id,
|
||||
{"name": "Updated Name", "contact_email": "new@test.com"},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result["name"] == "Updated Name"
|
||||
assert result["contact_email"] == "new@test.com"
|
||||
|
||||
def test_update_store_ignores_disallowed_fields(
|
||||
self, db, merchant_owner, merchant_store
|
||||
):
|
||||
"""Test that admin-only fields are ignored."""
|
||||
result = self.service.update_store(
|
||||
db,
|
||||
merchant_owner.id,
|
||||
merchant_store.id,
|
||||
{"is_active": False, "is_verified": True, "name": "OK Name"},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# is_active and is_verified should NOT change
|
||||
assert result["is_active"] is True
|
||||
assert result["is_verified"] is False
|
||||
assert result["name"] == "OK Name"
|
||||
|
||||
def test_update_store_wrong_merchant(
|
||||
self, db, other_merchant_owner, merchant_store
|
||||
):
|
||||
"""Test that updating another merchant's store raises not found."""
|
||||
with pytest.raises(StoreNotFoundException):
|
||||
self.service.update_store(
|
||||
db,
|
||||
other_merchant_owner.id,
|
||||
merchant_store.id,
|
||||
{"name": "Hacked"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestMerchantStoreServiceCreate:
|
||||
"""Tests for create_store."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MerchantStoreService()
|
||||
|
||||
def test_create_store_success(self, db, merchant_owner):
|
||||
"""Test successful store creation."""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
store_data = {
|
||||
"name": f"New Store {unique_id}",
|
||||
"store_code": f"NEW_{unique_id}",
|
||||
"subdomain": f"new-{unique_id}".lower(),
|
||||
"description": "Test description",
|
||||
"platform_ids": [],
|
||||
}
|
||||
|
||||
result = self.service.create_store(db, merchant_owner.id, store_data)
|
||||
db.commit()
|
||||
|
||||
assert result["name"] == store_data["name"]
|
||||
assert result["store_code"] == store_data["store_code"].upper()
|
||||
assert result["is_active"] is True
|
||||
assert result["is_verified"] is False
|
||||
|
||||
def test_create_store_duplicate_code(self, db, merchant_owner, merchant_store):
|
||||
"""Test creating store with duplicate store code."""
|
||||
store_data = {
|
||||
"name": "Another Store",
|
||||
"store_code": merchant_store.store_code,
|
||||
"subdomain": f"another-{uuid.uuid4().hex[:8]}",
|
||||
"description": None,
|
||||
"platform_ids": [],
|
||||
}
|
||||
|
||||
with pytest.raises(StoreAlreadyExistsException):
|
||||
self.service.create_store(db, merchant_owner.id, store_data)
|
||||
|
||||
def test_create_store_duplicate_subdomain(self, db, merchant_owner, merchant_store):
|
||||
"""Test creating store with duplicate subdomain."""
|
||||
store_data = {
|
||||
"name": "Another Store",
|
||||
"store_code": f"UNIQUE_{uuid.uuid4().hex[:8]}",
|
||||
"subdomain": merchant_store.subdomain,
|
||||
"description": None,
|
||||
"platform_ids": [],
|
||||
}
|
||||
|
||||
with pytest.raises(StoreValidationException):
|
||||
self.service.create_store(db, merchant_owner.id, store_data)
|
||||
|
||||
def test_create_store_nonexistent_merchant(self, db):
|
||||
"""Test creating store for non-existent merchant."""
|
||||
store_data = {
|
||||
"name": "No Merchant Store",
|
||||
"store_code": f"NM_{uuid.uuid4().hex[:8]}",
|
||||
"subdomain": f"nm-{uuid.uuid4().hex[:8]}",
|
||||
"description": None,
|
||||
"platform_ids": [],
|
||||
}
|
||||
|
||||
with pytest.raises(MerchantNotFoundException):
|
||||
self.service.create_store(db, 99999, store_data)
|
||||
|
||||
def test_create_store_creates_default_roles(self, db, merchant_owner):
|
||||
"""Test that default roles are created for new store."""
|
||||
from app.modules.tenancy.models.store import Role
|
||||
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
store_data = {
|
||||
"name": f"Roles Store {unique_id}",
|
||||
"store_code": f"ROLE_{unique_id}",
|
||||
"subdomain": f"role-{unique_id}".lower(),
|
||||
"description": None,
|
||||
"platform_ids": [],
|
||||
}
|
||||
|
||||
result = self.service.create_store(db, merchant_owner.id, store_data)
|
||||
db.commit()
|
||||
|
||||
roles = db.query(Role).filter(Role.store_id == result["id"]).all()
|
||||
role_names = {r.name for r in roles}
|
||||
|
||||
assert "Owner" in role_names
|
||||
assert "Manager" in role_names
|
||||
assert "Editor" in role_names
|
||||
assert "Viewer" in role_names
|
||||
Reference in New Issue
Block a user