refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -2,23 +2,110 @@
"""
Marketplace module exceptions.
Custom exceptions for Letzshop integration, product import/export, and sync operations.
This module provides exception classes for marketplace operations including:
- Letzshop integration (client, authentication, credentials)
- Product import/export
- Sync operations
- Onboarding wizard
"""
from app.exceptions import BusinessLogicException, ResourceNotFoundException
from typing import Any
from app.exceptions.base import (
AuthorizationException,
BusinessLogicException,
ConflictException,
ExternalServiceException,
ResourceNotFoundException,
ValidationException,
)
__all__ = [
# Base exception
"MarketplaceException",
# Letzshop client exceptions
"LetzshopClientError",
"LetzshopAuthenticationError",
"LetzshopCredentialsNotFoundException",
"LetzshopConnectionFailedException",
# Import job exceptions
"ImportJobNotFoundException",
"HistoricalImportJobNotFoundException",
"ImportJobNotOwnedException",
"ImportJobCannotBeCancelledException",
"ImportJobCannotBeDeletedException",
"ImportJobAlreadyProcessingException",
"ImportValidationError",
"InvalidImportDataException",
"ImportRateLimitException",
# Marketplace exceptions
"MarketplaceImportException",
"MarketplaceConnectionException",
"MarketplaceDataParsingException",
"InvalidMarketplaceException",
# Product exceptions
"VendorNotFoundException",
"ProductNotFoundException",
"MarketplaceProductNotFoundException",
"MarketplaceProductAlreadyExistsException",
"InvalidMarketplaceProductDataException",
"MarketplaceProductValidationException",
"InvalidGTINException",
"MarketplaceProductCSVImportException",
# Export/Sync exceptions
"ExportError",
"SyncError",
# Onboarding exceptions
"OnboardingNotFoundException",
"OnboardingStepOrderException",
"OnboardingAlreadyCompletedException",
"OnboardingCsvUrlRequiredException",
"OnboardingSyncJobNotFoundException",
"OnboardingSyncNotCompleteException",
]
# =============================================================================
# Base Marketplace Exception
# =============================================================================
class MarketplaceException(BusinessLogicException):
"""Base exception for marketplace module errors."""
pass
def __init__(
self,
message: str,
error_code: str = "MARKETPLACE_ERROR",
details: dict | None = None,
):
super().__init__(message=message, error_code=error_code, details=details)
# =============================================================================
# Letzshop Client Exceptions
# =============================================================================
class LetzshopClientError(MarketplaceException):
"""Raised when Letzshop API call fails."""
def __init__(self, message: str, status_code: int | None = None, response: str | None = None):
super().__init__(message)
def __init__(
self,
message: str,
status_code: int | None = None,
response: str | None = None,
):
details = {}
if status_code:
details["http_status_code"] = status_code
if response:
details["response"] = response
super().__init__(
message=message,
error_code="LETZSHOP_CLIENT_ERROR",
details=details if details else None,
)
self.status_code = status_code
self.response = response
@@ -28,57 +115,393 @@ class LetzshopAuthenticationError(LetzshopClientError):
def __init__(self, message: str = "Letzshop authentication failed"):
super().__init__(message, status_code=401)
self.error_code = "LETZSHOP_AUTHENTICATION_FAILED"
class LetzshopCredentialsNotFoundException(ResourceNotFoundException):
"""Raised when Letzshop credentials not found for vendor."""
def __init__(self, vendor_id: int):
super().__init__("LetzshopCredentials", str(vendor_id))
super().__init__(
resource_type="LetzshopCredentials",
identifier=str(vendor_id),
error_code="LETZSHOP_CREDENTIALS_NOT_FOUND",
)
self.vendor_id = vendor_id
class LetzshopConnectionFailedException(BusinessLogicException):
"""Raised when Letzshop API connection test fails."""
def __init__(self, error_message: str):
super().__init__(
message=f"Letzshop connection failed: {error_message}",
error_code="LETZSHOP_CONNECTION_FAILED",
details={"error": error_message},
)
# =============================================================================
# Import Job Exceptions
# =============================================================================
class ImportJobNotFoundException(ResourceNotFoundException):
"""Raised when a marketplace import job is not found."""
def __init__(self, job_id: int):
super().__init__("MarketplaceImportJob", str(job_id))
super().__init__(
resource_type="MarketplaceImportJob",
identifier=str(job_id),
message=f"Import job with ID '{job_id}' not found",
error_code="IMPORT_JOB_NOT_FOUND",
)
class HistoricalImportJobNotFoundException(ResourceNotFoundException):
"""Raised when a historical import job is not found."""
def __init__(self, job_id: int):
super().__init__("LetzshopHistoricalImportJob", str(job_id))
super().__init__(
resource_type="LetzshopHistoricalImportJob",
identifier=str(job_id),
error_code="HISTORICAL_IMPORT_JOB_NOT_FOUND",
)
class VendorNotFoundException(ResourceNotFoundException):
"""Raised when a vendor is not found."""
class ImportJobNotOwnedException(AuthorizationException):
"""Raised when user tries to access import job they don't own."""
def __init__(self, vendor_id: int):
super().__init__("Vendor", str(vendor_id))
def __init__(self, job_id: int, user_id: int | None = None):
details = {"job_id": job_id}
if user_id:
details["user_id"] = user_id
super().__init__(
message=f"Unauthorized access to import job '{job_id}'",
error_code="IMPORT_JOB_NOT_OWNED",
details=details,
)
class ProductNotFoundException(ResourceNotFoundException):
"""Raised when a marketplace product is not found."""
class ImportJobCannotBeCancelledException(BusinessLogicException):
"""Raised when trying to cancel job that cannot be cancelled."""
def __init__(self, product_id: str | int):
super().__init__("MarketplaceProduct", str(product_id))
def __init__(self, job_id: int, current_status: str):
super().__init__(
message=f"Import job '{job_id}' cannot be cancelled (current status: {current_status})",
error_code="IMPORT_JOB_CANNOT_BE_CANCELLED",
details={
"job_id": job_id,
"current_status": current_status,
},
)
class ImportJobCannotBeDeletedException(BusinessLogicException):
"""Raised when trying to delete job that cannot be deleted."""
def __init__(self, job_id: int, current_status: str):
super().__init__(
message=f"Import job '{job_id}' cannot be deleted (current status: {current_status})",
error_code="IMPORT_JOB_CANNOT_BE_DELETED",
details={
"job_id": job_id,
"current_status": current_status,
},
)
class ImportJobAlreadyProcessingException(BusinessLogicException):
"""Raised when trying to start import while another is already processing."""
def __init__(self, vendor_code: str, existing_job_id: int):
super().__init__(
message=f"Import already in progress for vendor '{vendor_code}'",
error_code="IMPORT_JOB_ALREADY_PROCESSING",
details={
"vendor_code": vendor_code,
"existing_job_id": existing_job_id,
},
)
class ImportValidationError(MarketplaceException):
"""Raised when import data validation fails."""
def __init__(self, message: str, errors: list[dict] | None = None):
super().__init__(message)
super().__init__(
message=message,
error_code="IMPORT_VALIDATION_ERROR",
details={"errors": errors} if errors else None,
)
self.errors = errors or []
class InvalidImportDataException(ValidationException):
"""Raised when import data is invalid."""
def __init__(
self,
message: str = "Invalid import data",
field: str | None = None,
row_number: int | None = None,
details: dict[str, Any] | None = None,
):
if not details:
details = {}
if row_number:
details["row_number"] = row_number
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_IMPORT_DATA"
class ImportRateLimitException(BusinessLogicException):
"""Raised when import rate limit is exceeded."""
def __init__(
self,
max_imports: int,
time_window: str,
retry_after: int | None = None,
):
details = {
"max_imports": max_imports,
"time_window": time_window,
}
if retry_after:
details["retry_after"] = retry_after
super().__init__(
message=f"Import rate limit exceeded: {max_imports} imports per {time_window}",
error_code="IMPORT_RATE_LIMIT_EXCEEDED",
details=details,
)
# =============================================================================
# Marketplace Exceptions
# =============================================================================
class MarketplaceImportException(BusinessLogicException):
"""Base exception for marketplace import operations."""
def __init__(
self,
message: str,
error_code: str = "MARKETPLACE_IMPORT_ERROR",
marketplace: str | None = None,
details: dict[str, Any] | None = None,
):
if not details:
details = {}
if marketplace:
details["marketplace"] = marketplace
super().__init__(
message=message,
error_code=error_code,
details=details,
)
class MarketplaceConnectionException(ExternalServiceException):
"""Raised when marketplace connection fails."""
def __init__(
self, marketplace: str, message: str = "Failed to connect to marketplace"
):
super().__init__(
service_name=marketplace,
message=f"{message}: {marketplace}",
error_code="MARKETPLACE_CONNECTION_FAILED",
)
class MarketplaceDataParsingException(ValidationException):
"""Raised when marketplace data cannot be parsed."""
def __init__(
self,
marketplace: str,
message: str = "Failed to parse marketplace data",
details: dict[str, Any] | None = None,
):
if not details:
details = {}
details["marketplace"] = marketplace
super().__init__(
message=f"{message} from {marketplace}",
details=details,
)
self.error_code = "MARKETPLACE_DATA_PARSING_FAILED"
class InvalidMarketplaceException(ValidationException):
"""Raised when marketplace is not supported."""
def __init__(self, marketplace: str, supported_marketplaces: list | None = None):
details = {"marketplace": marketplace}
if supported_marketplaces:
details["supported_marketplaces"] = supported_marketplaces
super().__init__(
message=f"Unsupported marketplace: {marketplace}",
field="marketplace",
details=details,
)
self.error_code = "INVALID_MARKETPLACE"
# =============================================================================
# Product Exceptions
# =============================================================================
class VendorNotFoundException(ResourceNotFoundException):
"""Raised when a vendor is not found."""
def __init__(self, vendor_id: int):
super().__init__(
resource_type="Vendor",
identifier=str(vendor_id),
error_code="VENDOR_NOT_FOUND",
)
class ProductNotFoundException(ResourceNotFoundException):
"""Raised when a marketplace product is not found."""
def __init__(self, product_id: str | int):
super().__init__(
resource_type="MarketplaceProduct",
identifier=str(product_id),
error_code="MARKETPLACE_PRODUCT_NOT_FOUND",
)
class MarketplaceProductNotFoundException(ResourceNotFoundException):
"""Raised when a product is not found."""
def __init__(self, marketplace_product_id: str):
super().__init__(
resource_type="MarketplaceProduct",
identifier=marketplace_product_id,
message=f"MarketplaceProduct with ID '{marketplace_product_id}' not found",
error_code="PRODUCT_NOT_FOUND",
)
class MarketplaceProductAlreadyExistsException(ConflictException):
"""Raised when trying to create a product that already exists."""
def __init__(self, marketplace_product_id: str):
super().__init__(
message=f"MarketplaceProduct with ID '{marketplace_product_id}' already exists",
error_code="PRODUCT_ALREADY_EXISTS",
details={"marketplace_product_id": marketplace_product_id},
)
class InvalidMarketplaceProductDataException(ValidationException):
"""Raised when product data is invalid."""
def __init__(
self,
message: str = "Invalid product data",
field: str | None = None,
details: dict[str, Any] | None = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_PRODUCT_DATA"
class MarketplaceProductValidationException(ValidationException):
"""Raised when product validation fails."""
def __init__(
self,
message: str,
field: str | None = None,
validation_errors: dict[str, str] | None = None,
):
details = {}
if validation_errors:
details["validation_errors"] = validation_errors
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "PRODUCT_VALIDATION_FAILED"
class InvalidGTINException(ValidationException):
"""Raised when GTIN format is invalid."""
def __init__(self, gtin: str, message: str = "Invalid GTIN format"):
super().__init__(
message=f"{message}: {gtin}",
field="gtin",
details={"gtin": gtin},
)
self.error_code = "INVALID_GTIN"
class MarketplaceProductCSVImportException(BusinessLogicException):
"""Raised when product CSV import fails."""
def __init__(
self,
message: str = "MarketplaceProduct CSV import failed",
row_number: int | None = None,
errors: dict[str, Any] | None = None,
):
details = {}
if row_number:
details["row_number"] = row_number
if errors:
details["errors"] = errors
super().__init__(
message=message,
error_code="PRODUCT_CSV_IMPORT_FAILED",
details=details,
)
# =============================================================================
# Export/Sync Exceptions
# =============================================================================
class ExportError(MarketplaceException):
"""Raised when product export fails."""
def __init__(self, message: str, language: str | None = None):
super().__init__(message)
details = {}
if language:
details["language"] = language
super().__init__(
message=message,
error_code="EXPORT_ERROR",
details=details if details else None,
)
self.language = language
@@ -86,20 +509,87 @@ class SyncError(MarketplaceException):
"""Raised when vendor directory sync fails."""
def __init__(self, message: str, vendor_code: str | None = None):
super().__init__(message)
details = {}
if vendor_code:
details["vendor_code"] = vendor_code
super().__init__(
message=message,
error_code="SYNC_ERROR",
details=details if details else None,
)
self.vendor_code = vendor_code
__all__ = [
"MarketplaceException",
"LetzshopClientError",
"LetzshopAuthenticationError",
"LetzshopCredentialsNotFoundException",
"ImportJobNotFoundException",
"HistoricalImportJobNotFoundException",
"VendorNotFoundException",
"ProductNotFoundException",
"ImportValidationError",
"ExportError",
"SyncError",
]
# =============================================================================
# Onboarding Exceptions
# =============================================================================
class OnboardingNotFoundException(ResourceNotFoundException):
"""Raised when onboarding record is not found for a vendor."""
def __init__(self, vendor_id: int):
super().__init__(
resource_type="VendorOnboarding",
identifier=str(vendor_id),
error_code="ONBOARDING_NOT_FOUND",
)
class OnboardingStepOrderException(ValidationException):
"""Raised when trying to access a step out of order."""
def __init__(self, current_step: str, required_step: str):
super().__init__(
message=f"Please complete the {required_step} step first",
field="step",
details={
"current_step": current_step,
"required_step": required_step,
},
)
self.error_code = "ONBOARDING_STEP_ORDER_ERROR"
class OnboardingAlreadyCompletedException(BusinessLogicException):
"""Raised when trying to modify a completed onboarding."""
def __init__(self, vendor_id: int):
super().__init__(
message="Onboarding has already been completed",
error_code="ONBOARDING_ALREADY_COMPLETED",
details={"vendor_id": vendor_id},
)
class OnboardingCsvUrlRequiredException(ValidationException):
"""Raised when no CSV URL is provided in product import step."""
def __init__(self):
super().__init__(
message="At least one CSV URL must be provided",
field="csv_url",
)
self.error_code = "ONBOARDING_CSV_URL_REQUIRED"
class OnboardingSyncJobNotFoundException(ResourceNotFoundException):
"""Raised when sync job is not found."""
def __init__(self, job_id: int):
super().__init__(
resource_type="LetzshopHistoricalImportJob",
identifier=str(job_id),
error_code="ONBOARDING_SYNC_JOB_NOT_FOUND",
)
class OnboardingSyncNotCompleteException(BusinessLogicException):
"""Raised when trying to complete onboarding before sync is done."""
def __init__(self, job_status: str):
super().__init__(
message=f"Import job is still {job_status}, please wait",
error_code="SYNC_NOT_COMPLETE",
details={"job_status": job_status},
)

View File

@@ -18,12 +18,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.exceptions import (
OrderHasUnresolvedExceptionsException,
ResourceNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.exceptions import ResourceNotFoundException, ValidationException
from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException
from app.modules.orders.services.order_item_exception_service import order_item_exception_service
from app.modules.marketplace.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
@@ -33,7 +30,7 @@ from app.modules.marketplace.services.letzshop import (
OrderNotFoundError,
VendorNotFoundError,
)
from app.tasks.letzshop_tasks import process_historical_import
from app.modules.marketplace.tasks import process_historical_import
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
FulfillmentOperationResponse,
@@ -1311,7 +1308,7 @@ def trigger_vendor_directory_sync(
the local cache. This is typically run daily via Celery beat, but
can be triggered manually here.
"""
from app.tasks.celery_tasks.letzshop import sync_vendor_directory
from app.modules.marketplace.tasks import sync_vendor_directory
# Try to dispatch via Celery first
try:

View File

@@ -13,8 +13,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from app.modules.analytics.services.stats_service import stats_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
AdminMarketplaceImportJobListResponse,

View File

@@ -0,0 +1,269 @@
# app/modules/marketplace/routes/api/public.py
"""
Public Letzshop vendor lookup API endpoints.
Allows potential vendors to find themselves in the Letzshop marketplace
and claim their shop during signup.
All endpoints are public (no authentication required).
"""
import logging
import re
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService
from app.modules.marketplace.models import LetzshopVendorCache
router = APIRouter(prefix="/letzshop-vendors")
logger = logging.getLogger(__name__)
# =============================================================================
# Response Schemas
# =============================================================================
class LetzshopVendorInfo(BaseModel):
"""Letzshop vendor information for display."""
letzshop_id: str | None = None
slug: str
name: str
company_name: str | None = None
description: str | None = None
email: str | None = None
phone: str | None = None
website: str | None = None
address: str | None = None
city: str | None = None
categories: list[str] = []
background_image_url: str | None = None
social_media_links: list[str] = []
letzshop_url: str
is_claimed: bool = False
@classmethod
def from_cache(cls, cache: LetzshopVendorCache, lang: str = "en") -> "LetzshopVendorInfo":
"""Create from cache entry."""
return cls(
letzshop_id=cache.letzshop_id,
slug=cache.slug,
name=cache.name,
company_name=cache.company_name,
description=cache.get_description(lang),
email=cache.email,
phone=cache.phone,
website=cache.website,
address=cache.get_full_address(),
city=cache.city,
categories=cache.categories or [],
background_image_url=cache.background_image_url,
social_media_links=cache.social_media_links or [],
letzshop_url=cache.letzshop_url,
is_claimed=cache.is_claimed,
)
class LetzshopVendorListResponse(BaseModel):
"""Paginated list of Letzshop vendors."""
vendors: list[LetzshopVendorInfo]
total: int
page: int
limit: int
has_more: bool
class LetzshopLookupRequest(BaseModel):
"""Request to lookup a Letzshop vendor by URL."""
url: str # e.g., https://letzshop.lu/vendors/my-shop or just "my-shop"
class LetzshopLookupResponse(BaseModel):
"""Response from Letzshop vendor lookup."""
found: bool
vendor: LetzshopVendorInfo | None = None
error: str | None = None
# =============================================================================
# Helper Functions
# =============================================================================
def extract_slug_from_url(url_or_slug: str) -> str:
"""
Extract vendor slug from Letzshop URL or return as-is if already a slug.
Handles:
- https://letzshop.lu/vendors/my-shop
- https://letzshop.lu/en/vendors/my-shop
- letzshop.lu/vendors/my-shop
- my-shop
"""
# Clean up the input
url_or_slug = url_or_slug.strip()
# If it looks like a URL, extract the slug
if "letzshop" in url_or_slug.lower() or "/" in url_or_slug:
# Remove protocol if present
url_or_slug = re.sub(r"^https?://", "", url_or_slug)
# Match pattern like letzshop.lu/[lang/]vendors/SLUG[/...]
match = re.search(r"letzshop\.lu/(?:[a-z]{2}/)?vendors?/([^/?#]+)", url_or_slug, re.IGNORECASE)
if match:
return match.group(1).lower()
# If just a path like vendors/my-shop
match = re.search(r"vendors?/([^/?#]+)", url_or_slug)
if match:
return match.group(1).lower()
# Return as-is (assume it's already a slug)
return url_or_slug.lower()
# =============================================================================
# Endpoints
# =============================================================================
@router.get("", response_model=LetzshopVendorListResponse) # public
async def list_letzshop_vendors(
search: Annotated[str | None, Query(description="Search by name")] = None,
category: Annotated[str | None, Query(description="Filter by category")] = None,
city: Annotated[str | None, Query(description="Filter by city")] = None,
only_unclaimed: Annotated[bool, Query(description="Only show unclaimed vendors")] = False,
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=50)] = 20,
db: Session = Depends(get_db),
) -> LetzshopVendorListResponse:
"""
List Letzshop vendors from cached directory.
The cache is periodically synced from Letzshop's public GraphQL API.
Run the sync task manually or wait for scheduled sync if cache is empty.
"""
sync_service = LetzshopVendorSyncService(db)
vendors, total = sync_service.search_cached_vendors(
search=search,
city=city,
category=category,
only_unclaimed=only_unclaimed,
page=page,
limit=limit,
)
return LetzshopVendorListResponse(
vendors=[LetzshopVendorInfo.from_cache(v, lang) for v in vendors],
total=total,
page=page,
limit=limit,
has_more=(page * limit) < total,
)
@router.post("/lookup", response_model=LetzshopLookupResponse) # public
async def lookup_letzshop_vendor(
request: LetzshopLookupRequest,
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
db: Session = Depends(get_db),
) -> LetzshopLookupResponse:
"""
Lookup a Letzshop vendor by URL or slug.
This endpoint:
1. Extracts the slug from the provided URL
2. Looks up vendor in local cache (or fetches from Letzshop if not cached)
3. Checks if the vendor is already claimed on our platform
4. Returns vendor info for signup pre-fill
"""
try:
slug = extract_slug_from_url(request.url)
if not slug:
return LetzshopLookupResponse(
found=False,
error="Could not extract vendor slug from URL",
)
sync_service = LetzshopVendorSyncService(db)
# First try cache
cache_entry = sync_service.get_cached_vendor(slug)
# If not in cache, try to fetch from Letzshop
if not cache_entry:
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
cache_entry = sync_service.sync_single_vendor(slug)
if not cache_entry:
return LetzshopLookupResponse(
found=False,
error="Vendor not found on Letzshop",
)
return LetzshopLookupResponse(
found=True,
vendor=LetzshopVendorInfo.from_cache(cache_entry, lang),
)
except Exception as e:
logger.error(f"Error looking up Letzshop vendor: {e}")
return LetzshopLookupResponse(
found=False,
error="Failed to lookup vendor",
)
@router.get("/stats") # public
async def get_letzshop_vendor_stats(
db: Session = Depends(get_db),
) -> dict:
"""
Get statistics about the Letzshop vendor cache.
Returns total, active, claimed, and unclaimed vendor counts.
"""
sync_service = LetzshopVendorSyncService(db)
return sync_service.get_sync_stats()
@router.get("/{slug}", response_model=LetzshopVendorInfo) # public
async def get_letzshop_vendor(
slug: str,
lang: Annotated[str, Query(description="Language for descriptions")] = "en",
db: Session = Depends(get_db),
) -> LetzshopVendorInfo:
"""
Get a specific Letzshop vendor by slug.
Returns 404 if vendor not found in cache or on Letzshop.
"""
slug = slug.lower()
sync_service = LetzshopVendorSyncService(db)
# First try cache
cache_entry = sync_service.get_cached_vendor(slug)
# If not in cache, try to fetch from Letzshop
if not cache_entry:
logger.info(f"Vendor {slug} not in cache, fetching from Letzshop...")
cache_entry = sync_service.sync_single_vendor(slug)
if not cache_entry:
raise ResourceNotFoundException("LetzshopVendor", slug)
return LetzshopVendorInfo.from_cache(cache_entry, lang)

View File

@@ -20,12 +20,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.exceptions import (
OrderHasUnresolvedExceptionsException,
ResourceNotFoundException,
ValidationException,
)
from app.services.order_item_exception_service import order_item_exception_service
from app.exceptions import ResourceNotFoundException, ValidationException
from app.modules.orders.exceptions import OrderHasUnresolvedExceptionsException
from app.modules.orders.services.order_item_exception_service import order_item_exception_service
from app.modules.marketplace.services.letzshop import (
CredentialsNotFoundError,
LetzshopClientError,
@@ -770,7 +767,7 @@ def export_products_letzshop(
from fastapi.responses import Response
from app.modules.marketplace.services.letzshop_export_service import letzshop_export_service
from app.services.vendor_service import vendor_service
from app.modules.tenancy.services.vendor_service import vendor_service
vendor_id = current_user.token_vendor_id
vendor = vendor_service.get_vendor_by_id(db, vendor_id)

View File

@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.modules.marketplace.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service
from app.modules.tenancy.services.vendor_service import vendor_service
from middleware.decorators import rate_limit
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (

View File

@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.services.onboarding_service import OnboardingService
from app.modules.marketplace.services.onboarding_service import OnboardingService
from models.schema.auth import UserContext
from app.modules.marketplace.schemas import (
CompanyProfileRequest,

View File

@@ -1,9 +1,2 @@
# app/modules/marketplace/routes/pages/__init__.py
"""
Marketplace module page routes (HTML rendering).
Provides Jinja2 template rendering for marketplace management.
Note: Page routes can be added here as needed.
"""
__all__ = []
"""Marketplace module page routes."""

View File

@@ -0,0 +1,243 @@
# app/modules/marketplace/routes/pages/admin.py
"""
Marketplace Admin Page Routes (HTML rendering).
Admin pages for marketplace management:
- Import history
- Background tasks
- Marketplace integration
- Letzshop management
- Marketplace products
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.core.config import settings
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# IMPORT MANAGEMENT ROUTES
# ============================================================================
@router.get("/imports", response_class=HTMLResponse, include_in_schema=False)
async def admin_imports_page(
request: Request,
current_user: User = Depends(require_menu_access("imports", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render imports management page.
Shows import history and status.
"""
return templates.TemplateResponse(
"marketplace/admin/imports.html",
get_admin_context(request, current_user),
)
@router.get("/background-tasks", response_class=HTMLResponse, include_in_schema=False)
async def admin_background_tasks_page(
request: Request,
current_user: User = Depends(
require_menu_access("background-tasks", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render background tasks monitoring page.
Shows running and completed background tasks across the system.
"""
return templates.TemplateResponse(
"marketplace/admin/background-tasks.html",
get_admin_context(request, current_user, flower_url=settings.flower_url),
)
@router.get("/marketplace", response_class=HTMLResponse, include_in_schema=False)
async def admin_marketplace_page(
request: Request,
current_user: User = Depends(
require_menu_access("marketplace-letzshop", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render marketplace import management page.
Allows admins to import products for any vendor and monitor all imports.
"""
return templates.TemplateResponse(
"marketplace/admin/marketplace.html",
get_admin_context(request, current_user),
)
# ============================================================================
# MARKETPLACE INTEGRATION ROUTES
# ============================================================================
@router.get(
"/marketplace/letzshop", response_class=HTMLResponse, include_in_schema=False
)
async def admin_marketplace_letzshop_page(
request: Request,
current_user: User = Depends(
require_menu_access("marketplace-letzshop", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render unified Letzshop management page.
Combines products (import/export), orders, and settings management.
Admin can select a vendor and manage their Letzshop integration.
"""
return templates.TemplateResponse(
"marketplace/admin/marketplace-letzshop.html",
get_admin_context(request, current_user),
)
@router.get(
"/letzshop/orders/{order_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_letzshop_order_detail_page(
request: Request,
order_id: int = Path(..., description="Letzshop order ID"),
current_user: User = Depends(
require_menu_access("marketplace-letzshop", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render detailed Letzshop order page.
Shows full order information with shipping address, billing address,
product details, and order history.
"""
return templates.TemplateResponse(
"marketplace/admin/letzshop-order-detail.html",
get_admin_context(request, current_user, order_id=order_id),
)
@router.get(
"/letzshop/products/{product_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_letzshop_product_detail_page(
request: Request,
product_id: int = Path(..., description="Marketplace Product ID"),
current_user: User = Depends(
require_menu_access("marketplace-letzshop", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render Letzshop product detail page.
Shows full product information from the marketplace.
"""
return templates.TemplateResponse(
"marketplace/admin/marketplace-product-detail.html",
get_admin_context(
request,
current_user,
product_id=product_id,
back_url="/admin/marketplace/letzshop",
),
)
# ============================================================================
# LETZSHOP VENDOR DIRECTORY
# ============================================================================
@router.get(
"/letzshop/vendor-directory",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_letzshop_vendor_directory_page(
request: Request,
current_user: User = Depends(
require_menu_access("marketplace-letzshop", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render Letzshop vendor directory management page.
Allows admins to:
- View cached Letzshop vendors
- Trigger manual sync from Letzshop API
- Create platform vendors from cached Letzshop vendors
"""
return templates.TemplateResponse(
"marketplace/admin/letzshop-vendor-directory.html",
get_admin_context(request, current_user),
)
# ============================================================================
# MARKETPLACE PRODUCTS ROUTES
# ============================================================================
@router.get(
"/marketplace-products", response_class=HTMLResponse, include_in_schema=False
)
async def admin_marketplace_products_page(
request: Request,
current_user: User = Depends(
require_menu_access("marketplace-letzshop", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render marketplace products page.
Browse the master product repository imported from external sources.
"""
return templates.TemplateResponse(
"marketplace/admin/marketplace-products.html",
get_admin_context(request, current_user),
)
@router.get(
"/marketplace-products/{product_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_marketplace_product_detail_page(
request: Request,
product_id: int = Path(..., description="Marketplace Product ID"),
current_user: User = Depends(
require_menu_access("marketplace-letzshop", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render marketplace product detail page.
Shows full product information from the master repository.
"""
return templates.TemplateResponse(
"marketplace/admin/marketplace-product-detail.html",
get_admin_context(
request,
current_user,
product_id=product_id,
back_url="/admin/marketplace-products",
),
)

View File

@@ -0,0 +1,41 @@
# app/modules/marketplace/routes/pages/public.py
"""
Marketplace Public Page Routes (HTML rendering).
Public (unauthenticated) pages:
- Find shop (Letzshop vendor browser)
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.core.utils.page_context import get_public_context
from app.templates_config import templates
router = APIRouter()
# ============================================================================
# FIND YOUR SHOP (LETZSHOP VENDOR BROWSER)
# ============================================================================
@router.get("/find-shop", response_class=HTMLResponse, name="platform_find_shop")
async def find_shop_page(
request: Request,
db: Session = Depends(get_db),
):
"""
Letzshop vendor browser page.
Allows vendors to search for and claim their Letzshop shop.
"""
context = get_public_context(request, db)
context["page_title"] = "Find Your Letzshop Shop"
return templates.TemplateResponse(
"marketplace/public/find-shop.html",
context,
)

View File

@@ -0,0 +1,146 @@
# app/modules/marketplace/routes/pages/vendor.py
"""
Marketplace Vendor Page Routes (HTML rendering).
Vendor pages for marketplace management:
- Onboarding wizard
- Dashboard
- Marketplace imports
- Letzshop integration
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context
from app.modules.marketplace.services.onboarding_service import OnboardingService
from app.templates_config import templates
from models.database.user import User
router = APIRouter()
# ============================================================================
# ONBOARDING WIZARD
# ============================================================================
@router.get(
"/{vendor_code}/onboarding", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_onboarding_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render vendor onboarding wizard.
Mandatory 4-step wizard that must be completed before accessing dashboard:
1. Company Profile Setup
2. Letzshop API Configuration
3. Product & Order Import Configuration
4. Order Sync (historical import)
If onboarding is already completed, redirects to dashboard.
"""
onboarding_service = OnboardingService(db)
if onboarding_service.is_completed(current_user.token_vendor_id):
return RedirectResponse(
url=f"/vendor/{vendor_code}/dashboard",
status_code=302,
)
return templates.TemplateResponse(
"marketplace/vendor/onboarding.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================
# VENDOR DASHBOARD
# ============================================================================
@router.get(
"/{vendor_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_dashboard_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render vendor dashboard.
Redirects to onboarding if not completed.
JavaScript will:
- Load vendor info via API
- Load dashboard stats via API
- Load recent orders via API
- Handle all interactivity
"""
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_vendor_id):
return RedirectResponse(
url=f"/vendor/{vendor_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse(
"core/vendor/dashboard.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================
# MARKETPLACE IMPORTS
# ============================================================================
@router.get(
"/{vendor_code}/marketplace", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_marketplace_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render marketplace import page.
JavaScript loads import jobs and products via API.
"""
return templates.TemplateResponse(
"marketplace/vendor/marketplace.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================
# LETZSHOP INTEGRATION
# ============================================================================
@router.get(
"/{vendor_code}/letzshop", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_letzshop_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render Letzshop integration page.
JavaScript loads orders, credentials status, and handles fulfillment operations.
"""
return templates.TemplateResponse(
"marketplace/vendor/letzshop.html",
get_vendor_context(request, db, current_user, vendor_code),
)

View File

@@ -18,6 +18,17 @@ from app.modules.marketplace.services.marketplace_product_service import (
MarketplaceProductService,
marketplace_product_service,
)
from app.modules.marketplace.services.onboarding_service import (
OnboardingService,
get_onboarding_service,
)
from app.modules.marketplace.services.platform_signup_service import (
PlatformSignupService,
platform_signup_service,
SignupSessionData,
AccountCreationResult,
SignupCompletionResult,
)
# Letzshop submodule services
from app.modules.marketplace.services.letzshop import (
@@ -42,6 +53,15 @@ __all__ = [
# Product service
"MarketplaceProductService",
"marketplace_product_service",
# Onboarding service
"OnboardingService",
"get_onboarding_service",
# Platform signup service
"PlatformSignupService",
"platform_signup_service",
"SignupSessionData",
"AccountCreationResult",
"SignupCompletionResult",
# Letzshop services
"LetzshopClient",
"LetzshopClientError",

View File

@@ -14,8 +14,8 @@ from typing import Any, Callable
from sqlalchemy import String, and_, func, or_
from sqlalchemy.orm import Session
from app.services.order_service import order_service as unified_order_service
from app.services.subscription_service import subscription_service
from app.modules.orders.services.order_service import order_service as unified_order_service
from app.modules.billing.services.subscription_service import subscription_service
from app.modules.marketplace.models import (
LetzshopFulfillmentQueue,
LetzshopHistoricalImportJob,

View File

@@ -436,7 +436,7 @@ class LetzshopVendorSyncService:
from sqlalchemy import func
from app.services.admin_service import admin_service
from app.modules.tenancy.services.admin_service import admin_service
from models.database.company import Company
from models.database.vendor import Vendor
from models.schema.vendor import VendorCreate

View File

@@ -3,10 +3,10 @@ import logging
from sqlalchemy.orm import Session
from app.exceptions import (
from app.exceptions import ValidationException
from app.modules.marketplace.exceptions import (
ImportJobNotFoundException,
ImportJobNotOwnedException,
ValidationException,
)
from app.modules.marketplace.models import (
MarketplaceImportError,
@@ -115,7 +115,7 @@ class MarketplaceImportJobService:
ImportJobNotFoundException: If job not found
UnauthorizedVendorAccessException: If job doesn't belong to vendor
"""
from app.exceptions import UnauthorizedVendorAccessException
from app.modules.tenancy.exceptions import UnauthorizedVendorAccessException
try:
job = (

View File

@@ -22,12 +22,12 @@ from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from app.exceptions import (
from app.exceptions import ValidationException
from app.modules.marketplace.exceptions import (
InvalidMarketplaceProductDataException,
MarketplaceProductAlreadyExistsException,
MarketplaceProductNotFoundException,
MarketplaceProductValidationException,
ValidationException,
)
from app.utils.data_processing import GTINProcessor, PriceProcessor
from app.modules.inventory.models import Inventory
@@ -865,7 +865,7 @@ class MarketplaceProductService:
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
from app.exceptions import VendorNotFoundException
from app.modules.tenancy.exceptions import VendorNotFoundException
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
@@ -880,7 +880,7 @@ class MarketplaceProductService:
raise MarketplaceProductNotFoundException("No marketplace products found")
# Check product limit from subscription
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
from sqlalchemy import func
current_products = (
@@ -998,7 +998,7 @@ class MarketplaceProductService:
# Auto-match pending order item exceptions
# Collect GTINs and their product IDs from newly copied products
from app.services.order_item_exception_service import (
from app.modules.orders.services.order_item_exception_service import (
order_item_exception_service,
)

View File

@@ -0,0 +1,664 @@
# app/modules/marketplace/services/onboarding_service.py
"""
Vendor onboarding service.
Handles the 4-step mandatory onboarding wizard for new vendors:
1. Company Profile Setup
2. Letzshop API Configuration
3. Product & Order Import (CSV feed URL configuration)
4. Order Sync (historical import with progress tracking)
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.marketplace.exceptions import (
OnboardingCsvUrlRequiredException,
OnboardingNotFoundException,
OnboardingStepOrderException,
OnboardingSyncJobNotFoundException,
OnboardingSyncNotCompleteException,
)
from app.modules.marketplace.services.letzshop import (
LetzshopCredentialsService,
LetzshopOrderService,
)
from app.modules.marketplace.models import (
OnboardingStatus,
OnboardingStep,
VendorOnboarding,
)
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class OnboardingService:
"""
Service for managing vendor onboarding workflow.
Provides methods for each onboarding step and progress tracking.
"""
def __init__(self, db: Session):
"""
Initialize the onboarding service.
Args:
db: SQLAlchemy database session.
"""
self.db = db
# =========================================================================
# Onboarding CRUD
# =========================================================================
def get_onboarding(self, vendor_id: int) -> VendorOnboarding | None:
"""Get onboarding record for a vendor."""
return (
self.db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == vendor_id)
.first()
)
def get_onboarding_or_raise(self, vendor_id: int) -> VendorOnboarding:
"""Get onboarding record or raise OnboardingNotFoundException."""
onboarding = self.get_onboarding(vendor_id)
if onboarding is None:
raise OnboardingNotFoundException(vendor_id)
return onboarding
def create_onboarding(self, vendor_id: int) -> VendorOnboarding:
"""
Create a new onboarding record for a vendor.
This is called automatically when a vendor is created during signup.
"""
# Check if already exists
existing = self.get_onboarding(vendor_id)
if existing:
logger.warning(f"Onboarding already exists for vendor {vendor_id}")
return existing
onboarding = VendorOnboarding(
vendor_id=vendor_id,
status=OnboardingStatus.NOT_STARTED.value,
current_step=OnboardingStep.COMPANY_PROFILE.value,
)
self.db.add(onboarding)
self.db.flush()
logger.info(f"Created onboarding record for vendor {vendor_id}")
return onboarding
def get_or_create_onboarding(self, vendor_id: int) -> VendorOnboarding:
"""Get existing onboarding or create new one."""
onboarding = self.get_onboarding(vendor_id)
if onboarding is None:
onboarding = self.create_onboarding(vendor_id)
return onboarding
# =========================================================================
# Status Helpers
# =========================================================================
def is_completed(self, vendor_id: int) -> bool:
"""Check if onboarding is completed for a vendor."""
onboarding = self.get_onboarding(vendor_id)
if onboarding is None:
return False
return onboarding.is_completed
def get_status_response(self, vendor_id: int) -> dict:
"""
Get full onboarding status for API response.
Returns a dictionary with all step statuses and progress information.
"""
onboarding = self.get_or_create_onboarding(vendor_id)
return {
"id": onboarding.id,
"vendor_id": onboarding.vendor_id,
"status": onboarding.status,
"current_step": onboarding.current_step,
# Step statuses
"company_profile": {
"completed": onboarding.step_company_profile_completed,
"completed_at": onboarding.step_company_profile_completed_at,
"data": onboarding.step_company_profile_data,
},
"letzshop_api": {
"completed": onboarding.step_letzshop_api_completed,
"completed_at": onboarding.step_letzshop_api_completed_at,
"connection_verified": onboarding.step_letzshop_api_connection_verified,
},
"product_import": {
"completed": onboarding.step_product_import_completed,
"completed_at": onboarding.step_product_import_completed_at,
"csv_url_set": onboarding.step_product_import_csv_url_set,
},
"order_sync": {
"completed": onboarding.step_order_sync_completed,
"completed_at": onboarding.step_order_sync_completed_at,
"job_id": onboarding.step_order_sync_job_id,
},
# Progress tracking
"completion_percentage": onboarding.completion_percentage,
"completed_steps_count": onboarding.completed_steps_count,
"total_steps": 4,
# Completion info
"is_completed": onboarding.is_completed,
"started_at": onboarding.started_at,
"completed_at": onboarding.completed_at,
# Admin override info
"skipped_by_admin": onboarding.skipped_by_admin,
"skipped_at": onboarding.skipped_at,
"skipped_reason": onboarding.skipped_reason,
}
# =========================================================================
# Step 1: Company Profile
# =========================================================================
def get_company_profile_data(self, vendor_id: int) -> dict:
"""Get current company profile data for editing."""
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
return {}
company = vendor.company
return {
"company_name": company.name if company else None,
"brand_name": vendor.name,
"description": vendor.description,
"contact_email": vendor.effective_contact_email,
"contact_phone": vendor.effective_contact_phone,
"website": vendor.effective_website,
"business_address": vendor.effective_business_address,
"tax_number": vendor.effective_tax_number,
"default_language": vendor.default_language,
"dashboard_language": vendor.dashboard_language,
}
def complete_company_profile(
self,
vendor_id: int,
company_name: str | None = None,
brand_name: str | None = None,
description: str | None = None,
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 = "fr",
dashboard_language: str = "fr",
) -> dict:
"""
Save company profile and mark Step 1 as complete.
Returns response with next step information.
"""
# Check vendor exists BEFORE creating onboarding record (FK constraint)
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(vendor_id)
onboarding = self.get_or_create_onboarding(vendor_id)
# Update onboarding status if this is the first step
if onboarding.status == OnboardingStatus.NOT_STARTED.value:
onboarding.status = OnboardingStatus.IN_PROGRESS.value
onboarding.started_at = datetime.now(UTC)
company = vendor.company
# Update company name if provided
if company and company_name:
company.name = company_name
# Update vendor fields
if brand_name:
vendor.name = brand_name
if description is not None:
vendor.description = description
# Update contact info (vendor-level overrides)
vendor.contact_email = contact_email
vendor.contact_phone = contact_phone
vendor.website = website
vendor.business_address = business_address
vendor.tax_number = tax_number
# Update language settings
vendor.default_language = default_language
vendor.dashboard_language = dashboard_language
# Store profile data in onboarding record
onboarding.step_company_profile_data = {
"company_name": company_name,
"brand_name": brand_name,
"description": description,
"contact_email": contact_email,
"contact_phone": contact_phone,
"website": website,
"business_address": business_address,
"tax_number": tax_number,
"default_language": default_language,
"dashboard_language": dashboard_language,
}
# Mark step complete
onboarding.mark_step_complete(OnboardingStep.COMPANY_PROFILE.value)
self.db.flush()
logger.info(f"Completed company profile step for vendor {vendor_id}")
return {
"success": True,
"step_completed": True,
"next_step": onboarding.current_step,
"message": "Company profile saved successfully",
}
# =========================================================================
# Step 2: Letzshop API Configuration
# =========================================================================
def test_letzshop_api(
self,
api_key: str,
shop_slug: str,
) -> dict:
"""
Test Letzshop API connection without saving credentials.
Returns connection test result with vendor info if successful.
"""
credentials_service = LetzshopCredentialsService(self.db)
# Test the API key
success, response_time, error = credentials_service.test_api_key(api_key)
if success:
return {
"success": True,
"message": f"Connection successful ({response_time:.0f}ms)",
"vendor_name": None, # Would need to query Letzshop for this
"vendor_id": None,
"shop_slug": shop_slug,
}
else:
return {
"success": False,
"message": error or "Connection failed",
"vendor_name": None,
"vendor_id": None,
"shop_slug": None,
}
def complete_letzshop_api(
self,
vendor_id: int,
api_key: str,
shop_slug: str,
letzshop_vendor_id: str | None = None,
) -> dict:
"""
Save Letzshop API credentials and mark Step 2 as complete.
Tests connection first, only saves if successful.
"""
onboarding = self.get_or_create_onboarding(vendor_id)
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.LETZSHOP_API.value):
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.COMPANY_PROFILE.value,
)
# Test connection first
credentials_service = LetzshopCredentialsService(self.db)
success, response_time, error = credentials_service.test_api_key(api_key)
if not success:
return {
"success": False,
"step_completed": False,
"next_step": None,
"message": f"Connection test failed: {error}",
"connection_verified": False,
}
# Save credentials
credentials_service.upsert_credentials(
vendor_id=vendor_id,
api_key=api_key,
auto_sync_enabled=False, # Enable after onboarding
sync_interval_minutes=15,
)
# Update vendor with Letzshop identity
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if vendor:
vendor.letzshop_vendor_slug = shop_slug
if letzshop_vendor_id:
vendor.letzshop_vendor_id = letzshop_vendor_id
# Mark step complete
onboarding.step_letzshop_api_connection_verified = True
onboarding.mark_step_complete(OnboardingStep.LETZSHOP_API.value)
self.db.flush()
logger.info(f"Completed Letzshop API step for vendor {vendor_id}")
return {
"success": True,
"step_completed": True,
"next_step": onboarding.current_step,
"message": "Letzshop API configured successfully",
"connection_verified": True,
}
# =========================================================================
# Step 3: Product & Order Import Configuration
# =========================================================================
def get_product_import_config(self, vendor_id: int) -> dict:
"""Get current product import configuration."""
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
return {}
return {
"csv_url_fr": vendor.letzshop_csv_url_fr,
"csv_url_en": vendor.letzshop_csv_url_en,
"csv_url_de": vendor.letzshop_csv_url_de,
"default_tax_rate": vendor.letzshop_default_tax_rate,
"delivery_method": vendor.letzshop_delivery_method,
"preorder_days": vendor.letzshop_preorder_days,
}
def complete_product_import(
self,
vendor_id: int,
csv_url_fr: str | None = None,
csv_url_en: str | None = None,
csv_url_de: str | None = None,
default_tax_rate: int = 17,
delivery_method: str = "package_delivery",
preorder_days: int = 1,
) -> dict:
"""
Save product import configuration and mark Step 3 as complete.
At least one CSV URL must be provided.
"""
onboarding = self.get_or_create_onboarding(vendor_id)
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.PRODUCT_IMPORT.value):
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.LETZSHOP_API.value,
)
# Validate at least one CSV URL
csv_urls_count = sum([
bool(csv_url_fr),
bool(csv_url_en),
bool(csv_url_de),
])
if csv_urls_count == 0:
raise OnboardingCsvUrlRequiredException()
# Update vendor settings
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(vendor_id)
vendor.letzshop_csv_url_fr = csv_url_fr
vendor.letzshop_csv_url_en = csv_url_en
vendor.letzshop_csv_url_de = csv_url_de
vendor.letzshop_default_tax_rate = default_tax_rate
vendor.letzshop_delivery_method = delivery_method
vendor.letzshop_preorder_days = preorder_days
# Mark step complete
onboarding.step_product_import_csv_url_set = True
onboarding.mark_step_complete(OnboardingStep.PRODUCT_IMPORT.value)
self.db.flush()
logger.info(f"Completed product import step for vendor {vendor_id}")
return {
"success": True,
"step_completed": True,
"next_step": onboarding.current_step,
"message": "Product import configured successfully",
"csv_urls_configured": csv_urls_count,
}
# =========================================================================
# Step 4: Order Sync
# =========================================================================
def trigger_order_sync(
self,
vendor_id: int,
user_id: int,
days_back: int = 90,
include_products: bool = True,
) -> dict:
"""
Trigger historical order import and return job info.
Creates a background job that imports historical orders from Letzshop.
"""
onboarding = self.get_or_create_onboarding(vendor_id)
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.ORDER_SYNC.value):
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.PRODUCT_IMPORT.value,
)
# Create historical import job
order_service = LetzshopOrderService(self.db)
# Check for existing running job
existing_job = order_service.get_running_historical_import_job(vendor_id)
if existing_job:
return {
"success": True,
"message": "Import job already running",
"job_id": existing_job.id,
"estimated_duration_minutes": 5, # Estimate
}
# Create new job
job = order_service.create_historical_import_job(
vendor_id=vendor_id,
user_id=user_id,
)
# Store job ID in onboarding
onboarding.step_order_sync_job_id = job.id
self.db.flush()
logger.info(f"Triggered order sync job {job.id} for vendor {vendor_id}")
return {
"success": True,
"message": "Historical import started",
"job_id": job.id,
"estimated_duration_minutes": 5, # Estimate
}
def get_order_sync_progress(
self,
vendor_id: int,
job_id: int,
) -> dict:
"""
Get progress of historical import job.
Returns current status, progress, and counts.
"""
order_service = LetzshopOrderService(self.db)
job = order_service.get_historical_import_job_by_id(vendor_id, job_id)
if not job:
return {
"job_id": job_id,
"status": "not_found",
"progress_percentage": 0,
"current_phase": None,
"orders_imported": 0,
"orders_total": None,
"products_imported": 0,
"started_at": None,
"completed_at": None,
"estimated_remaining_seconds": None,
"error_message": "Job not found",
}
# Calculate progress percentage
progress = 0
if job.status == "completed":
progress = 100
elif job.status == "failed":
progress = 0
elif job.status == "processing":
# Use orders_processed and shipments_fetched for progress
total = job.shipments_fetched or 0
processed = job.orders_processed or 0
if total > 0:
progress = int(processed / total * 100)
else:
progress = 50 # Indeterminate
elif job.status == "fetching":
# Show partial progress during fetch phase
if job.total_pages and job.total_pages > 0:
progress = int((job.current_page or 0) / job.total_pages * 50)
else:
progress = 25 # Indeterminate
# Determine current phase
current_phase = job.current_phase or job.status
return {
"job_id": job.id,
"status": job.status,
"progress_percentage": progress,
"current_phase": current_phase,
"orders_imported": job.orders_imported or 0,
"orders_total": job.shipments_fetched,
"products_imported": job.products_matched or 0,
"started_at": job.started_at,
"completed_at": job.completed_at,
"estimated_remaining_seconds": None, # TODO: Calculate
"error_message": job.error_message,
}
def complete_order_sync(
self,
vendor_id: int,
job_id: int,
) -> dict:
"""
Mark order sync step as complete after job finishes.
Also marks the entire onboarding as complete.
"""
onboarding = self.get_or_create_onboarding(vendor_id)
# Verify job is complete
order_service = LetzshopOrderService(self.db)
job = order_service.get_historical_import_job_by_id(vendor_id, job_id)
if not job:
raise OnboardingSyncJobNotFoundException(job_id)
if job.status not in ("completed", "failed"):
raise OnboardingSyncNotCompleteException(job.status)
# Mark step complete (even if job failed - they can retry later)
onboarding.mark_step_complete(OnboardingStep.ORDER_SYNC.value)
# Enable auto-sync now that onboarding is complete
credentials_service = LetzshopCredentialsService(self.db)
credentials = credentials_service.get_credentials(vendor_id)
if credentials:
credentials.auto_sync_enabled = True
self.db.flush()
# Get vendor code for redirect URL
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
vendor_code = vendor.vendor_code if vendor else ""
logger.info(f"Completed onboarding for vendor {vendor_id}")
return {
"success": True,
"step_completed": True,
"onboarding_completed": True,
"message": "Onboarding complete! Welcome to Wizamart.",
"redirect_url": f"/vendor/{vendor_code}/dashboard",
}
# =========================================================================
# Admin Skip
# =========================================================================
def skip_onboarding(
self,
vendor_id: int,
admin_user_id: int,
reason: str,
) -> dict:
"""
Admin-only: Skip onboarding for a vendor.
Used for support cases where manual setup is needed.
"""
onboarding = self.get_or_create_onboarding(vendor_id)
onboarding.skipped_by_admin = True
onboarding.skipped_at = datetime.now(UTC)
onboarding.skipped_reason = reason
onboarding.skipped_by_user_id = admin_user_id
onboarding.status = OnboardingStatus.SKIPPED.value
self.db.flush()
logger.info(
f"Admin {admin_user_id} skipped onboarding for vendor {vendor_id}: {reason}"
)
return {
"success": True,
"message": "Onboarding skipped by admin",
"vendor_id": vendor_id,
"skipped_at": onboarding.skipped_at,
}
# Singleton-style convenience instance
def get_onboarding_service(db: Session) -> OnboardingService:
"""Get an OnboardingService instance."""
return OnboardingService(db)

View File

@@ -0,0 +1,649 @@
# app/modules/marketplace/services/platform_signup_service.py
"""
Platform signup service.
Handles all database operations for the platform signup flow:
- Session management
- Vendor claiming
- Account creation
- Subscription setup
"""
import logging
import secrets
from datetime import UTC, datetime, timedelta
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.core.config import settings
from app.exceptions import (
ConflictException,
ResourceNotFoundException,
ValidationException,
)
from app.modules.messaging.services.email_service import EmailService
from app.modules.marketplace.services.onboarding_service import OnboardingService
from app.modules.billing.services.stripe_service import stripe_service
from middleware.auth import AuthManager
from models.database.company import Company
from app.modules.billing.models import (
SubscriptionStatus,
TierCode,
TIER_LIMITS,
VendorSubscription,
)
from models.database.user import User
from models.database.vendor import Vendor, VendorUser, VendorUserType
logger = logging.getLogger(__name__)
# =============================================================================
# In-memory signup session storage
# In production, use Redis or database table
# =============================================================================
_signup_sessions: dict[str, dict] = {}
def _create_session_id() -> str:
"""Generate a secure session ID."""
return secrets.token_urlsafe(32)
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class SignupSessionData:
"""Data stored in a signup session."""
session_id: str
step: str
tier_code: str
is_annual: bool
created_at: str
updated_at: str | None = None
letzshop_slug: str | None = None
letzshop_vendor_id: str | None = None
vendor_name: str | None = None
user_id: int | None = None
vendor_id: int | None = None
vendor_code: str | None = None
stripe_customer_id: str | None = None
setup_intent_id: str | None = None
@dataclass
class AccountCreationResult:
"""Result of account creation."""
user_id: int
vendor_id: int
vendor_code: str
stripe_customer_id: str
@dataclass
class SignupCompletionResult:
"""Result of signup completion."""
success: bool
vendor_code: str
vendor_id: int
redirect_url: str
trial_ends_at: str
access_token: str | None = None # JWT token for automatic login
# =============================================================================
# Platform Signup Service
# =============================================================================
class PlatformSignupService:
"""Service for handling platform signup operations."""
def __init__(self):
self.auth_manager = AuthManager()
# =========================================================================
# Session Management
# =========================================================================
def create_session(self, tier_code: str, is_annual: bool) -> str:
"""
Create a new signup session.
Args:
tier_code: The subscription tier code
is_annual: Whether annual billing is selected
Returns:
The session ID
Raises:
ValidationException: If tier code is invalid
"""
# Validate tier code
try:
tier = TierCode(tier_code)
except ValueError:
raise ValidationException(
message=f"Invalid tier code: {tier_code}",
field="tier_code",
)
session_id = _create_session_id()
now = datetime.now(UTC).isoformat()
_signup_sessions[session_id] = {
"step": "tier_selected",
"tier_code": tier.value,
"is_annual": is_annual,
"created_at": now,
"updated_at": now,
}
logger.info(f"Created signup session {session_id} for tier {tier.value}")
return session_id
def get_session(self, session_id: str) -> dict | None:
"""Get a signup session by ID."""
return _signup_sessions.get(session_id)
def get_session_or_raise(self, session_id: str) -> dict:
"""
Get a signup session or raise an exception.
Raises:
ResourceNotFoundException: If session not found
"""
session = self.get_session(session_id)
if not session:
raise ResourceNotFoundException(
resource_type="SignupSession",
identifier=session_id,
)
return session
def update_session(self, session_id: str, data: dict) -> None:
"""Update signup session data."""
session = self.get_session_or_raise(session_id)
session.update(data)
session["updated_at"] = datetime.now(UTC).isoformat()
_signup_sessions[session_id] = session
def delete_session(self, session_id: str) -> None:
"""Delete a signup session."""
_signup_sessions.pop(session_id, None)
# =========================================================================
# Vendor Claiming
# =========================================================================
def check_vendor_claimed(self, db: Session, letzshop_slug: str) -> bool:
"""Check if a Letzshop vendor is already claimed."""
return (
db.query(Vendor)
.filter(
Vendor.letzshop_vendor_slug == letzshop_slug,
Vendor.is_active == True,
)
.first()
is not None
)
def claim_vendor(
self,
db: Session,
session_id: str,
letzshop_slug: str,
letzshop_vendor_id: str | None = None,
) -> str:
"""
Claim a Letzshop vendor for signup.
Args:
db: Database session
session_id: Signup session ID
letzshop_slug: Letzshop vendor slug
letzshop_vendor_id: Optional Letzshop vendor ID
Returns:
Generated vendor name
Raises:
ResourceNotFoundException: If session not found
ConflictException: If vendor already claimed
"""
session = self.get_session_or_raise(session_id)
# Check if vendor is already claimed
if self.check_vendor_claimed(db, letzshop_slug):
raise ConflictException(
message="This Letzshop vendor is already claimed",
)
# Generate vendor name from slug
vendor_name = letzshop_slug.replace("-", " ").title()
# Update session
self.update_session(session_id, {
"letzshop_slug": letzshop_slug,
"letzshop_vendor_id": letzshop_vendor_id,
"vendor_name": vendor_name,
"step": "vendor_claimed",
})
logger.info(f"Claimed vendor {letzshop_slug} for session {session_id}")
return vendor_name
# =========================================================================
# Account Creation
# =========================================================================
def check_email_exists(self, db: Session, email: str) -> bool:
"""Check if an email already exists."""
return db.query(User).filter(User.email == email).first() is not None
def generate_unique_username(self, db: Session, email: str) -> str:
"""Generate a unique username from email."""
username = email.split("@")[0]
base_username = username
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{base_username}_{counter}"
counter += 1
return username
def generate_unique_vendor_code(self, db: Session, company_name: str) -> str:
"""Generate a unique vendor code from company name."""
vendor_code = company_name.upper().replace(" ", "_")[:20]
base_code = vendor_code
counter = 1
while db.query(Vendor).filter(Vendor.vendor_code == vendor_code).first():
vendor_code = f"{base_code}_{counter}"
counter += 1
return vendor_code
def generate_unique_subdomain(self, db: Session, company_name: str) -> str:
"""Generate a unique subdomain from company name."""
subdomain = company_name.lower().replace(" ", "-")
subdomain = "".join(c for c in subdomain if c.isalnum() or c == "-")[:50]
base_subdomain = subdomain
counter = 1
while db.query(Vendor).filter(Vendor.subdomain == subdomain).first():
subdomain = f"{base_subdomain}-{counter}"
counter += 1
return subdomain
def create_account(
self,
db: Session,
session_id: str,
email: str,
password: str,
first_name: str,
last_name: str,
company_name: str,
phone: str | None = None,
) -> AccountCreationResult:
"""
Create user, company, vendor, and Stripe customer.
Args:
db: Database session
session_id: Signup session ID
email: User email
password: User password
first_name: User first name
last_name: User last name
company_name: Company name
phone: Optional phone number
Returns:
AccountCreationResult with IDs
Raises:
ResourceNotFoundException: If session not found
ConflictException: If email already exists
"""
session = self.get_session_or_raise(session_id)
# Check if email already exists
if self.check_email_exists(db, email):
raise ConflictException(
message="An account with this email already exists",
)
# Generate unique username
username = self.generate_unique_username(db, email)
# Create User
user = User(
email=email,
username=username,
hashed_password=self.auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role="vendor",
is_active=True,
)
db.add(user)
db.flush()
# Create Company
company = Company(
name=company_name,
owner_user_id=user.id,
contact_email=email,
contact_phone=phone,
)
db.add(company)
db.flush()
# Generate unique vendor code and subdomain
vendor_code = self.generate_unique_vendor_code(db, company_name)
subdomain = self.generate_unique_subdomain(db, company_name)
# Create Vendor
vendor = Vendor(
company_id=company.id,
vendor_code=vendor_code,
subdomain=subdomain,
name=company_name,
contact_email=email,
contact_phone=phone,
is_active=True,
letzshop_vendor_slug=session.get("letzshop_slug"),
letzshop_vendor_id=session.get("letzshop_vendor_id"),
)
db.add(vendor)
db.flush()
# Create VendorUser (owner)
vendor_user = VendorUser(
vendor_id=vendor.id,
user_id=user.id,
user_type=VendorUserType.OWNER.value,
is_active=True,
)
db.add(vendor_user)
# Create VendorOnboarding record
onboarding_service = OnboardingService(db)
onboarding_service.create_onboarding(vendor.id)
# Create Stripe Customer
stripe_customer_id = stripe_service.create_customer(
vendor=vendor,
email=email,
name=f"{first_name} {last_name}",
metadata={
"company_name": company_name,
"tier": session.get("tier_code"),
},
)
# Create VendorSubscription (trial status)
now = datetime.now(UTC)
trial_end = now + timedelta(days=settings.stripe_trial_days)
subscription = VendorSubscription(
vendor_id=vendor.id,
tier=session.get("tier_code", TierCode.ESSENTIAL.value),
status=SubscriptionStatus.TRIAL.value,
period_start=now,
period_end=trial_end,
trial_ends_at=trial_end,
is_annual=session.get("is_annual", False),
stripe_customer_id=stripe_customer_id,
)
db.add(subscription)
db.commit() # noqa: SVC-006 - Atomic account creation needs commit
# Update session
self.update_session(session_id, {
"user_id": user.id,
"vendor_id": vendor.id,
"vendor_code": vendor_code,
"stripe_customer_id": stripe_customer_id,
"step": "account_created",
})
logger.info(
f"Created account for {email}: user_id={user.id}, vendor_id={vendor.id}"
)
return AccountCreationResult(
user_id=user.id,
vendor_id=vendor.id,
vendor_code=vendor_code,
stripe_customer_id=stripe_customer_id,
)
# =========================================================================
# Payment Setup
# =========================================================================
def setup_payment(self, session_id: str) -> tuple[str, str]:
"""
Create Stripe SetupIntent for card collection.
Args:
session_id: Signup session ID
Returns:
Tuple of (client_secret, stripe_customer_id)
Raises:
EntityNotFoundException: If session not found
ValidationException: If account not created yet
"""
session = self.get_session_or_raise(session_id)
if "stripe_customer_id" not in session:
raise ValidationException(
message="Account not created. Please complete step 3 first.",
field="session_id",
)
stripe_customer_id = session["stripe_customer_id"]
# Create SetupIntent
setup_intent = stripe_service.create_setup_intent(
customer_id=stripe_customer_id,
metadata={
"session_id": session_id,
"vendor_id": str(session.get("vendor_id")),
"tier": session.get("tier_code"),
},
)
# Update session
self.update_session(session_id, {
"setup_intent_id": setup_intent.id,
"step": "payment_pending",
})
logger.info(f"Created SetupIntent {setup_intent.id} for session {session_id}")
return setup_intent.client_secret, stripe_customer_id
# =========================================================================
# Welcome Email
# =========================================================================
def send_welcome_email(
self,
db: Session,
user: User,
vendor: Vendor,
tier_code: str,
language: str = "fr",
) -> None:
"""
Send welcome email to new vendor.
Args:
db: Database session
user: User who signed up
vendor: Vendor that was created
tier_code: Selected tier code
language: Language for email (default: French)
"""
try:
# Get tier name
tier_enum = TierCode(tier_code)
tier_name = TIER_LIMITS.get(tier_enum, {}).get("name", tier_code.title())
# Build login URL
login_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/dashboard"
email_service = EmailService(db)
email_service.send_template(
template_code="signup_welcome",
language=language,
to_email=user.email,
to_name=f"{user.first_name} {user.last_name}",
variables={
"first_name": user.first_name,
"company_name": vendor.name,
"email": user.email,
"vendor_code": vendor.vendor_code,
"login_url": login_url,
"trial_days": settings.stripe_trial_days,
"tier_name": tier_name,
},
vendor_id=vendor.id,
user_id=user.id,
related_type="signup",
)
logger.info(f"Welcome email sent to {user.email}")
except Exception as e:
# Log error but don't fail signup
logger.error(f"Failed to send welcome email to {user.email}: {e}")
# =========================================================================
# Signup Completion
# =========================================================================
def complete_signup(
self,
db: Session,
session_id: str,
setup_intent_id: str,
) -> SignupCompletionResult:
"""
Complete signup after card collection.
Args:
db: Database session
session_id: Signup session ID
setup_intent_id: Stripe SetupIntent ID
Returns:
SignupCompletionResult
Raises:
EntityNotFoundException: If session not found
ValidationException: If signup incomplete or payment failed
"""
session = self.get_session_or_raise(session_id)
vendor_id = session.get("vendor_id")
stripe_customer_id = session.get("stripe_customer_id")
if not vendor_id or not stripe_customer_id:
raise ValidationException(
message="Incomplete signup. Please start again.",
field="session_id",
)
# Retrieve SetupIntent to get payment method
setup_intent = stripe_service.get_setup_intent(setup_intent_id)
if setup_intent.status != "succeeded":
raise ValidationException(
message="Card setup not completed. Please try again.",
field="setup_intent_id",
)
payment_method_id = setup_intent.payment_method
# Attach payment method to customer
stripe_service.attach_payment_method_to_customer(
customer_id=stripe_customer_id,
payment_method_id=payment_method_id,
set_as_default=True,
)
# Update subscription record
subscription = (
db.query(VendorSubscription)
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if subscription:
subscription.card_collected_at = datetime.now(UTC)
subscription.stripe_payment_method_id = payment_method_id
db.commit() # noqa: SVC-006 - Finalize signup needs commit
# Get vendor info
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
vendor_code = vendor.vendor_code if vendor else session.get("vendor_code")
trial_ends_at = (
subscription.trial_ends_at
if subscription
else datetime.now(UTC) + timedelta(days=30)
)
# Get user for welcome email and token generation
user_id = session.get("user_id")
user = db.query(User).filter(User.id == user_id).first() if user_id else None
# Generate access token for automatic login after signup
access_token = None
if user and vendor:
# Create vendor-scoped JWT token (user is owner since they just signed up)
token_data = self.auth_manager.create_access_token(
user=user,
vendor_id=vendor.id,
vendor_code=vendor.vendor_code,
vendor_role="Owner", # New signup is always the owner
)
access_token = token_data["access_token"]
logger.info(f"Generated access token for new vendor user {user.email}")
# Send welcome email
if user and vendor:
tier_code = session.get("tier_code", TierCode.ESSENTIAL.value)
self.send_welcome_email(db, user, vendor, tier_code)
# Clean up session
self.delete_session(session_id)
logger.info(f"Completed signup for vendor {vendor_id}")
# Redirect to onboarding instead of dashboard
return SignupCompletionResult(
success=True,
vendor_code=vendor_code,
vendor_id=vendor_id,
redirect_url=f"/vendor/{vendor_code}/onboarding",
trial_ends_at=trial_ends_at.isoformat(),
access_token=access_token,
)
# Singleton instance
platform_signup_service = PlatformSignupService()

View File

@@ -14,7 +14,7 @@ from typing import Callable
from app.core.celery_config import celery_app
from app.modules.marketplace.models import MarketplaceImportJob, LetzshopHistoricalImportJob
from app.services.admin_notification_service import admin_notification_service
from app.modules.messaging.services.admin_notification_service import admin_notification_service
from app.modules.marketplace.services.letzshop import (
LetzshopClientError,
LetzshopCredentialsService,

View File

@@ -10,7 +10,7 @@ from typing import Any
from app.core.celery_config import celery_app
from app.modules.task_base import ModuleTask
from app.services.admin_notification_service import admin_notification_service
from app.modules.messaging.services.admin_notification_service import admin_notification_service
from app.modules.marketplace.services.letzshop import LetzshopVendorSyncService
logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,282 @@
{# app/templates/admin/background-tasks.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% block title %}Background Tasks{% endblock %}
{% block alpine_data %}backgroundTasks(){% endblock %}
{% block extra_scripts %}
<script src="/static/admin/js/background-tasks.js"></script>
{% endblock %}
{% block content %}
{% call page_header_flex(title='Background Tasks', subtitle='Monitor running and completed background tasks') %}
<!-- Flower Dashboard Link (Celery Monitoring) -->
<a href="{{ flower_url }}"
target="_blank"
rel="noopener noreferrer"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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 mr-2"
title="Open Flower dashboard for detailed Celery task monitoring">
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Flower Dashboard
</a>
{{ refresh_button(variant='secondary') }}
{% endcall %}
{{ loading_state('Loading tasks...') }}
{{ error_state('Error loading tasks') }}
<!-- Dashboard Content -->
<div x-show="!loading && !error">
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Running Tasks -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('refresh', 'w-5 h-5 animate-spin')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Running</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.running">0</p>
</div>
</div>
<!-- Completed Today -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Today</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.tasks_today">0</p>
</div>
</div>
<!-- Failed -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Failed</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.failed">0</p>
</div>
</div>
<!-- Total -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('collection', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_tasks">0</p>
</div>
</div>
</div>
<!-- Running Tasks Section -->
<div class="mb-8" x-show="runningTasks.length > 0">
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
<span x-html="$icon('refresh', 'w-5 h-5 mr-2 animate-spin text-yellow-500')"></span>
Currently Running
</h4>
<div class="space-y-3">
<template x-for="task in runningTasks" :key="task.task_type + '-' + task.id">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center justify-between">
<div class="flex items-center">
<span class="px-2 py-1 text-xs font-semibold rounded-full mr-3"
:class="{
'bg-blue-100 text-blue-700 dark:bg-blue-700 dark:text-blue-100': task.task_type === 'import',
'bg-purple-100 text-purple-700 dark:bg-purple-700 dark:text-purple-100': task.task_type === 'test_run'
}"
x-text="task.task_type === 'import' ? 'Import' : 'Test Run'">
</span>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="task.description"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Started by <span x-text="task.triggered_by || 'system'"></span>
at <span x-text="task.started_at ? new Date(task.started_at).toLocaleTimeString() : 'N/A'"></span>
</p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-bold text-yellow-600 dark:text-yellow-400" x-text="formatDuration(task.duration_seconds)"></p>
<p class="text-xs text-gray-500">elapsed</p>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Task Type Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Import Jobs Stats -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
<span x-html="$icon('cube', 'w-5 h-5 mr-2 text-blue-500')"></span>
Import Jobs
</h4>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="stats.import_jobs?.total || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
</div>
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-yellow-600" x-text="stats.import_jobs?.running || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Running</p>
</div>
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-green-600" x-text="stats.import_jobs?.completed || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Completed</p>
</div>
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-red-600" x-text="stats.import_jobs?.failed || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Failed</p>
</div>
</div>
<div class="mt-4 text-center">
<a href="/admin/imports" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
View Import Jobs &rarr;
</a>
</div>
</div>
<!-- Test Runs Stats -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
<span x-html="$icon('beaker', 'w-5 h-5 mr-2 text-purple-500')"></span>
Test Runs
</h4>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="stats.test_runs?.total || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
</div>
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-yellow-600" x-text="stats.test_runs?.running || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Running</p>
</div>
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-green-600" x-text="stats.test_runs?.completed || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Passed</p>
</div>
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-2xl font-bold text-red-600" x-text="stats.test_runs?.failed || 0"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Failed</p>
</div>
</div>
<div class="mt-4 text-center">
<a href="/admin/testing" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
View Test Dashboard &rarr;
</a>
</div>
</div>
</div>
<!-- Filter Tabs -->
<div class="mb-4">
<div class="flex space-x-2">
<button @click="filterType = null; loadTasks()"
:class="filterType === null ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
All Tasks
</button>
<button @click="filterType = 'import'; loadTasks()"
:class="filterType === 'import' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
Imports
</button>
<button @click="filterType = 'test_run'; loadTasks()"
:class="filterType === 'test_run' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
Test Runs
</button>
</div>
</div>
<!-- Tasks Table -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Recent Tasks
</h4>
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Description</th>
<th class="px-4 py-3">Started</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Triggered By</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Celery</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="task in tasks" :key="task.task_type + '-' + task.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-blue-100 text-blue-700 dark:bg-blue-700 dark:text-blue-100': task.task_type === 'import',
'bg-purple-100 text-purple-700 dark:bg-purple-700 dark:text-purple-100': task.task_type === 'test_run'
}"
x-text="task.task_type === 'import' ? 'Import' : 'Test Run'">
</span>
</td>
<td class="px-4 py-3">
<p class="font-medium truncate max-w-xs" x-text="task.description"></p>
<p x-show="task.error_message" class="text-xs text-red-500 truncate max-w-xs" x-text="task.error_message"></p>
</td>
<td class="px-4 py-3 text-sm" x-text="task.started_at ? new Date(task.started_at).toLocaleString() : 'N/A'"></td>
<td class="px-4 py-3 text-sm" x-text="formatDuration(task.duration_seconds)"></td>
<td class="px-4 py-3 text-sm" x-text="task.triggered_by || 'system'"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100': task.status === 'completed' || task.status === 'passed',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100': task.status === 'running' || task.status === 'processing' || task.status === 'pending',
'bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100': task.status === 'failed' || task.status === 'error',
'bg-orange-100 text-orange-700 dark:bg-orange-700 dark:text-orange-100': task.status === 'completed_with_errors',
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-100': !['completed', 'passed', 'running', 'processing', 'pending', 'failed', 'error', 'completed_with_errors'].includes(task.status)
}"
x-text="task.status">
</span>
</td>
<td class="px-4 py-3 text-sm">
<template x-if="task.celery_task_id">
<a :href="'{{ flower_url }}/task/' + task.celery_task_id"
target="_blank"
rel="noopener noreferrer"
class="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 underline"
title="View in Flower">
<span x-html="$icon('external-link', 'w-4 h-4 inline')"></span>
<span x-text="task.celery_task_id.substring(0, 8) + '...'"></span>
</a>
</template>
<template x-if="!task.celery_task_id">
<span class="text-gray-400 text-xs">-</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="tasks.length === 0">
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<span x-html="$icon('collection', 'w-12 h-12 mx-auto mb-2 text-gray-400')"></span>
<p>No tasks found</p>
</div>
</template>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,263 @@
{# app/templates/admin/imports.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import error_state %}
{% from 'shared/macros/modals.html' import job_details_modal %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Import Jobs - Platform Monitoring{% endblock %}
{% block alpine_data %}adminImports(){% endblock %}
{% block extra_scripts %}
<script src="/static/admin/js/imports.js"></script>
{% endblock %}
{% block content %}
{% call page_header_flex(title='Platform Import Jobs', subtitle='System-wide monitoring of all marketplace import jobs') %}
{{ refresh_button(onclick='refreshJobs()') }}
{% endcall %}
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Total Jobs -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Jobs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
0
</p>
</div>
</div>
<!-- Active Jobs -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active Jobs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active">
0
</p>
</div>
</div>
<!-- Completed -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Completed
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.completed">
0
</p>
</div>
</div>
<!-- Failed -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Failed
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.failed">
0
</p>
</div>
</div>
</div>
{{ error_state('Error', show_condition='error') }}
<!-- Filters -->
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
<div class="grid gap-4 md:grid-cols-5">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
Filter by Vendor
</label>
<select
x-model="filters.vendor_id"
@change="applyFilters()"
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Vendors</option>
<template x-for="vendor in vendors" :key="vendor.id">
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
</template>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
Filter by Status
</label>
<select
x-model="filters.status"
@change="applyFilters()"
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="completed_with_errors">Completed with Errors</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
Filter by Marketplace
</label>
<select
x-model="filters.marketplace"
@change="applyFilters()"
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Marketplaces</option>
<option value="Letzshop">Letzshop</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
Created By
</label>
<select
x-model="filters.created_by"
@change="applyFilters()"
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Users</option>
<option value="me">My Jobs Only</option>
</select>
</div>
<div class="flex items-end">
<button
@click="clearFilters()"
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
>
Clear Filters
</button>
</div>
</div>
</div>
<!-- Import Jobs List -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Import Jobs
</h3>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
</div>
<!-- Empty State -->
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
<p class="text-gray-600 dark:text-gray-400">No import jobs found</p>
<p class="text-sm text-gray-500 dark:text-gray-500">Try adjusting your filters or wait for new imports</p>
</div>
<!-- Jobs Table -->
<div x-show="!loading && jobs.length > 0">
{% call table_wrapper() %}
{{ table_header(['Job ID', 'Vendor', 'Marketplace', 'Status', 'Progress', 'Started', 'Duration', 'Created By', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm">
#<span x-text="job.id"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="getVendorName(job.vendor_id)"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.marketplace"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
}"
x-text="job.status.replace('_', ' ').toUpperCase()">
</span>
</td>
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
</div>
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
<span x-text="job.error_count"></span> errors
</div>
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
Total: <span x-text="job.total_processed"></span>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="calculateDuration(job)"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.created_by_name || 'System'"></span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<button
@click="viewJobDetails(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
<button
x-show="job.status === 'processing' || job.status === 'pending'"
@click="refreshJobStatus(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Refresh Status"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination(show_condition="!loading && pagination.total > 0") }}
</div>
</div>
{{ job_details_modal(show_created_by=true) }}
{% endblock %}

View File

@@ -0,0 +1,315 @@
{# app/templates/admin/letzshop-order-detail.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% block title %}Letzshop Order Details{% endblock %}
{% block alpine_data %}letzshopOrderDetail(){% endblock %}
{% block content %}
<main class="h-full pb-16 overflow-y-auto">
<div class="container grid px-6 mx-auto">
<!-- Header -->
<div class="flex items-center justify-between my-6">
<div class="flex items-center gap-4">
<a
href="/admin/marketplace/letzshop"
class="flex items-center text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a>
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Order <span x-text="order?.external_order_number || order?.order_number || 'Loading...'"></span>
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Letzshop Order Details
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
x-show="order"
class="px-3 py-1 text-sm rounded-full font-medium"
:class="{
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300': order?.status === 'pending',
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300': order?.status === 'processing',
'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300': order?.status === 'cancelled',
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': order?.status === 'shipped'
}"
x-text="order?.status === 'cancelled' ? 'DECLINED' : (order?.status === 'processing' ? 'CONFIRMED' : order?.status?.toUpperCase())"
></span>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
<!-- Error State -->
{{ error_state('Failed to load order', 'error') }}
<!-- Order Content -->
<div x-show="order && !loading" class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Order Information -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
Order Information
</h4>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Order Date</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="formatDate(order?.order_date || order?.created_at)"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Order Number</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.external_order_number || order?.order_number"></span>
</div>
<div class="flex justify-between" x-show="order?.shipment_number">
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
<span class="font-mono font-medium text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
</div>
<div class="flex justify-between" x-show="order?.external_shipment_id">
<span class="text-gray-500 dark:text-gray-400">Hash ID</span>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400" x-text="order?.external_shipment_id?.split('/').pop()"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Total</span>
<span class="font-semibold text-gray-700 dark:text-gray-300" x-text="order?.total_amount + ' ' + order?.currency"></span>
</div>
<div class="flex justify-between" x-show="order?.confirmed_at">
<span class="text-gray-500 dark:text-gray-400">Confirmed At</span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.confirmed_at)"></span>
</div>
<div class="flex justify-between" x-show="order?.cancelled_at">
<span class="text-gray-500 dark:text-gray-400">Declined At</span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.cancelled_at)"></span>
</div>
</div>
</div>
<!-- Customer Information -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('user', 'w-5 h-5')"></span>
Customer Information
</h4>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Name</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.customer_name || 'N/A'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Email</span>
<a :href="'mailto:' + order?.customer_email" class="text-purple-600 hover:underline" x-text="order?.customer_email"></a>
</div>
<div class="flex justify-between" x-show="order?.customer_locale">
<span class="text-gray-500 dark:text-gray-400">Language</span>
<span class="uppercase font-medium text-gray-700 dark:text-gray-300" x-text="order?.customer_locale"></span>
</div>
</div>
</div>
<!-- Shipping Address -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800" x-show="order">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('location-marker', 'w-5 h-5')"></span>
Shipping Address
</h4>
<div class="text-sm text-gray-700 dark:text-gray-300 space-y-1">
<p x-text="order?.ship_first_name + ' ' + order?.ship_last_name"></p>
<p x-show="order?.ship_company" x-text="order?.ship_company"></p>
<p x-text="order?.ship_address_line_1"></p>
<p x-show="order?.ship_address_line_2" x-text="order?.ship_address_line_2"></p>
<p x-text="order?.ship_postal_code + ' ' + order?.ship_city"></p>
<p x-text="order?.ship_country_iso"></p>
<p x-show="order?.customer_phone" class="mt-2">
<span class="text-gray-500 dark:text-gray-400">Phone:</span>
<span x-text="order?.customer_phone"></span>
</p>
</div>
</div>
<!-- Shipping & Tracking Information -->
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('truck', 'w-5 h-5')"></span>
Shipping & Tracking
</h4>
<div x-show="order?.shipment_number || order?.shipping_carrier || order?.tracking_number || order?.tracking_url" class="space-y-3 text-sm">
<div class="flex justify-between" x-show="order?.shipping_carrier">
<span class="text-gray-500 dark:text-gray-400">Carrier</span>
<span class="font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="order?.shipping_carrier"></span>
</div>
<div class="flex justify-between" x-show="order?.shipment_number">
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
</div>
<div class="flex justify-between" x-show="order?.tracking_number">
<span class="text-gray-500 dark:text-gray-400">Tracking Number</span>
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.tracking_number"></span>
</div>
<div class="flex justify-between" x-show="order?.tracking_provider">
<span class="text-gray-500 dark:text-gray-400">Tracking Provider</span>
<span class="text-gray-700 dark:text-gray-300" x-text="order?.tracking_provider"></span>
</div>
<div class="flex justify-between" x-show="order?.shipped_at">
<span class="text-gray-500 dark:text-gray-400">Shipped At</span>
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.shipped_at)"></span>
</div>
<!-- Tracking Link -->
<div x-show="order?.tracking_url || (order?.shipping_carrier === 'greco' && order?.shipment_number)" class="pt-2 border-t dark:border-gray-700">
<a
:href="order?.tracking_url || ('https://dispatchweb.fr/Tracky/Home/' + order?.shipment_number)"
target="_blank"
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-purple-600 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-400 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors"
>
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
View Tracking / Download Label
</a>
</div>
</div>
<div x-show="!order?.shipment_number && !order?.shipping_carrier && !order?.tracking_number && !order?.tracking_url" class="text-sm text-gray-500 dark:text-gray-400 italic">
No shipping information available yet
</div>
</div>
</div>
<!-- Order Items -->
<div x-show="order && !loading" class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 mb-8">
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('shopping-bag', 'w-5 h-5')"></span>
Order Items
<span class="text-sm font-normal text-gray-500">
(<span x-text="order?.items?.length || 0"></span> items)
</span>
</h4>
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">GTIN/SKU</th>
<th class="px-4 py-3">Qty</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Status</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="item in order?.items || []" :key="item.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3">
<div class="flex items-center">
<!-- Placeholder for product image -->
<div class="w-10 h-10 rounded bg-gray-200 dark:bg-gray-600 flex items-center justify-center mr-3">
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
</div>
<div>
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="item.product_name || 'Unknown Product'"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<p x-show="item.gtin" class="font-mono">
<span x-text="item.gtin"></span>
<span x-show="item.gtin_type" class="text-xs text-gray-400" x-text="'(' + item.gtin_type + ')'"></span>
</p>
<p x-show="item.product_sku" class="text-xs text-gray-500">
SKU: <span x-text="item.product_sku"></span>
</p>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="item.quantity"></span>
</td>
<td class="px-4 py-3 text-sm font-medium">
<span x-text="item.unit_price ? (item.unit_price + ' ' + order?.currency) : 'N/A'"></span>
<p x-show="item.quantity > 1" class="text-xs text-gray-500">
Total: <span x-text="item.total_price + ' ' + order?.currency"></span>
</p>
</td>
<td class="px-4 py-3">
<span
class="px-2 py-1 text-xs rounded-full font-medium"
:class="{
'bg-orange-100 text-orange-700': !item.item_state,
'bg-green-100 text-green-700': item.item_state === 'confirmed_available',
'bg-red-100 text-red-700': item.item_state === 'confirmed_unavailable',
'bg-gray-100 text-gray-700': item.item_state === 'returned'
}"
x-text="item.item_state === 'confirmed_unavailable' ? 'DECLINED' : (item.item_state === 'confirmed_available' ? 'CONFIRMED' : (item.item_state ? item.item_state.toUpperCase() : 'PENDING'))"
></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Raw Order Data (collapsible) -->
<div x-show="order && !loading" class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 mb-8">
<button
@click="showRawData = !showRawData"
class="w-full flex items-center justify-between text-left"
>
<h4 class="font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
<span x-html="$icon('code', 'w-5 h-5')"></span>
Raw Marketplace Data
</h4>
<span x-html="showRawData ? $icon('chevron-up', 'w-5 h-5 text-gray-500') : $icon('chevron-down', 'w-5 h-5 text-gray-500')"></span>
</button>
<div x-show="showRawData" class="mt-4">
<pre class="text-xs bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto max-h-96"><code x-text="JSON.stringify(order?.external_data, null, 2)"></code></pre>
</div>
</div>
</div>
</main>
{% endblock %}
{% block extra_scripts %}
<script>
function letzshopOrderDetail() {
return {
...data(),
currentPage: 'marketplace-letzshop',
orderId: {{ order_id }},
order: null,
loading: true,
error: null,
showRawData: false,
async init() {
await this.loadOrder();
},
async loadOrder() {
this.loading = true;
this.error = null;
try {
// Fetch the order detail from the API
const response = await apiClient.get(`/admin/letzshop/orders/${this.orderId}`);
this.order = response;
} catch (err) {
console.error('Failed to load order:', err);
this.error = err.message || 'Failed to load order details';
} finally {
this.loading = false;
}
},
formatDate(dateString) {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,430 @@
{# app/templates/admin/letzshop-vendor-directory.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/pagination.html' import pagination_controls %}
{% block title %}Letzshop Vendor Directory{% endblock %}
{% block alpine_data %}letzshopVendorDirectory(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Letzshop Vendor Directory', subtitle='Browse and import vendors from Letzshop marketplace') %}
<div class="flex items-center gap-3">
<button
@click="triggerSync()"
:disabled="syncing"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
<span x-show="!syncing" x-html="$icon('arrow-path', 'w-4 h-4 mr-2')"></span>
<span x-show="syncing" class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span x-text="syncing ? 'Syncing...' : 'Sync from Letzshop'"></span>
</button>
{{ refresh_button(loading_var='loading', onclick='loadVendors()', variant='secondary') }}
</div>
{% endcall %}
<!-- Success/Error Messages -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 flex-shrink-0')"></span>
<span x-text="successMessage"></span>
<button @click="successMessage = ''" class="ml-auto">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
{{ error_state('Error', show_condition='error && !loading') }}
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Vendors</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="stats.total_vendors || 0"></p>
</div>
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('building-storefront', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Active</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active_vendors || 0"></p>
</div>
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Claimed</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="stats.claimed_vendors || 0"></p>
</div>
<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('user-check', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Unclaimed</p>
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400" x-text="stats.unclaimed_vendors || 0"></p>
</div>
<div class="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
<span x-html="$icon('user-plus', 'w-5 h-5 text-amber-600 dark:text-amber-400')"></span>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" x-show="stats.last_synced_at">
Last sync: <span x-text="formatDate(stats.last_synced_at)"></span>
</p>
</div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input
type="text"
x-model="filters.search"
@input.debounce.300ms="loadVendors()"
placeholder="Search by name..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
</div>
<!-- City -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
<input
type="text"
x-model="filters.city"
@input.debounce.300ms="loadVendors()"
placeholder="Filter by city..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
</div>
<!-- Category -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<input
type="text"
x-model="filters.category"
@input.debounce.300ms="loadVendors()"
placeholder="Filter by category..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
</div>
<!-- Only Unclaimed -->
<div class="flex items-end">
<label class="inline-flex items-center cursor-pointer">
<input
type="checkbox"
x-model="filters.only_unclaimed"
@change="loadVendors()"
class="sr-only peer"
>
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
<span class="ms-3 text-sm font-medium text-gray-700 dark:text-gray-300">Only Unclaimed</span>
</label>
</div>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- Vendors Table -->
<div x-show="!loading" x-cloak class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Empty State -->
<div x-show="vendors.length === 0" class="text-center py-12">
<span x-html="$icon('building-storefront', 'w-12 h-12 mx-auto text-gray-400')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No vendors found</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">
<span x-show="stats.total_vendors === 0">Click "Sync from Letzshop" to import vendors.</span>
<span x-show="stats.total_vendors > 0">Try adjusting your filters.</span>
</p>
</div>
<!-- Table -->
<div x-show="vendors.length > 0" class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Vendor</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contact</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categories</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="vendor in vendors" :key="vendor.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center">
<div class="flex-shrink-0 w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-400" x-text="vendor.name?.charAt(0).toUpperCase()"></span>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="vendor.name"></div>
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.company_name"></div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.email || '-'"></div>
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.phone || ''"></div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.city || '-'"></div>
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
<template x-for="cat in (vendor.categories || []).slice(0, 2)" :key="cat">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200" x-text="cat"></span>
</template>
<span x-show="(vendor.categories || []).length > 2" class="text-xs text-gray-500">+<span x-text="vendor.categories.length - 2"></span></span>
</div>
</td>
<td class="px-6 py-4">
<span
x-show="vendor.is_claimed"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
>
<span x-html="$icon('check', 'w-3 h-3 mr-1')"></span>
Claimed
</span>
<span
x-show="!vendor.is_claimed"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300"
>
Available
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<a
:href="vendor.letzshop_url"
target="_blank"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="View on Letzshop"
>
<span x-html="$icon('arrow-top-right-on-square', 'w-5 h-5')"></span>
</a>
<button
@click="showVendorDetail(vendor)"
class="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300"
title="View Details"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
x-show="!vendor.is_claimed"
@click="openCreateVendorModal(vendor)"
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300"
title="Create Platform Vendor"
>
<span x-html="$icon('plus-circle', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div x-show="vendors.length > 0" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400">
Showing <span x-text="((page - 1) * limit) + 1"></span> to <span x-text="Math.min(page * limit, total)"></span> of <span x-text="total"></span> vendors
</div>
<div class="flex items-center gap-2">
<button
@click="page--; loadVendors()"
:disabled="page <= 1"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
Previous
</button>
<span class="px-3 py-1 text-sm">Page <span x-text="page"></span></span>
<button
@click="page++; loadVendors()"
:disabled="!hasMore"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
>
Next
</button>
</div>
</div>
</div>
</div>
<!-- Vendor Detail Modal -->
<div
x-show="showDetailModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
@keydown.escape.window="showDetailModal = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showDetailModal = false"></div>
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-2xl p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedVendor?.name"></h3>
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<div x-show="selectedVendor" class="space-y-4">
<!-- Company Name -->
<div x-show="selectedVendor?.company_name">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Company</p>
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.company_name"></p>
</div>
<!-- Contact -->
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</p>
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.email || '-'"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone</p>
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.phone || '-'"></p>
</div>
</div>
<!-- Address -->
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</p>
<p class="text-gray-900 dark:text-white">
<span x-text="selectedVendor?.city || '-'"></span>
</p>
</div>
<!-- Categories -->
<div x-show="selectedVendor?.categories?.length">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Categories</p>
<div class="flex flex-wrap gap-2">
<template x-for="cat in (selectedVendor?.categories || [])" :key="cat">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300" x-text="cat"></span>
</template>
</div>
</div>
<!-- Website -->
<div x-show="selectedVendor?.website">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Website</p>
<a :href="selectedVendor?.website" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.website"></a>
</div>
<!-- Letzshop URL -->
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Letzshop Page</p>
<a :href="selectedVendor?.letzshop_url" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.letzshop_url"></a>
</div>
<!-- Actions -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button @click="showDetailModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
Close
</button>
<button
x-show="!selectedVendor?.is_claimed"
@click="showDetailModal = false; openCreateVendorModal(selectedVendor)"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg"
>
Create Vendor
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Create Vendor Modal -->
<div
x-show="showCreateModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
@keydown.escape.window="showCreateModal = false"
>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showCreateModal = false"></div>
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-md p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Create Vendor from Letzshop</h3>
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Create a platform vendor from <strong x-text="createVendorData?.name"></strong>
</p>
<!-- Company Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Select Company <span class="text-red-500">*</span>
</label>
<select
x-model="createVendorData.company_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">-- Select a company --</option>
<template x-for="company in companies" :key="company.id">
<option :value="company.id" x-text="company.name"></option>
</template>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The vendor will be created under this company</p>
</div>
<!-- Error -->
<div x-show="createError" class="p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg text-sm">
<span x-text="createError"></span>
</div>
<!-- Actions -->
<div class="pt-4 flex justify-end gap-3">
<button @click="showCreateModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
Cancel
</button>
<button
@click="createVendor()"
:disabled="!createVendorData.company_id || creating"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
>
<span x-show="!creating">Create Vendor</span>
<span x-show="creating" class="flex items-center">
<span class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
Creating...
</span>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('marketplace_static', path='admin/js/letzshop-vendor-directory.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,359 @@
{# app/templates/admin/letzshop.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import error_state, alert_dynamic %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}Letzshop Management{% endblock %}
{% block alpine_data %}adminLetzshop(){% endblock %}
{% block extra_scripts %}
<script src="/static/admin/js/letzshop.js"></script>
{% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Letzshop Management', subtitle='Manage Letzshop integration for all vendors') %}
{{ refresh_button(loading_var='loading', onclick='refreshData()') }}
{% endcall %}
<!-- Success Message -->
{{ alert_dynamic(type='success', title='', message_var='successMessage', show_condition='successMessage') }}
<!-- Error Message -->
{{ error_state(title='Error', error_var='error', show_condition='error && !loading') }}
<!-- Summary Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Total Vendors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:bg-purple-900">
<span x-html="$icon('office-building', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Vendors</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total"></p>
</div>
</div>
<!-- Configured -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Configured</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.configured"></p>
</div>
</div>
<!-- Auto-Sync Enabled -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:bg-blue-900">
<span x-html="$icon('refresh', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Auto-Sync</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.autoSync"></p>
</div>
</div>
<!-- Pending Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Pending Orders</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.pendingOrders"></p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap gap-4">
<label class="flex items-center">
<input
type="checkbox"
x-model="filters.configuredOnly"
@change="loadVendors()"
class="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Configured only</span>
</label>
</div>
<!-- Vendors Table -->
{% call table_wrapper() %}
{{ table_header(['Vendor', 'Status', 'Auto-Sync', 'Last Sync', 'Orders', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading && vendors.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading vendors...</p>
</td>
</tr>
</template>
<template x-if="!loading && vendors.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('office-building', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No vendors found</p>
</td>
</tr>
</template>
<template x-for="vendor in vendors" :key="vendor.vendor_id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="vendor.vendor_name.charAt(0).toUpperCase()"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="vendor.vendor_name"></p>
<p class="text-xs text-gray-500" x-text="vendor.vendor_code"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span
class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="vendor.is_configured ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-600 dark:text-gray-100'"
x-text="vendor.is_configured ? 'CONFIGURED' : 'NOT CONFIGURED'"
></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-show="vendor.auto_sync_enabled" class="text-green-600 dark:text-green-400">
<span x-html="$icon('check', 'w-4 h-4 inline')"></span> Enabled
</span>
<span x-show="!vendor.auto_sync_enabled" class="text-gray-400">
<span x-html="$icon('x', 'w-4 h-4 inline')"></span> Disabled
</span>
</td>
<td class="px-4 py-3 text-sm">
<div x-show="vendor.last_sync_at">
<span
class="px-2 py-0.5 text-xs rounded-full"
:class="{
'bg-green-100 text-green-700': vendor.last_sync_status === 'success',
'bg-yellow-100 text-yellow-700': vendor.last_sync_status === 'partial',
'bg-red-100 text-red-700': vendor.last_sync_status === 'failed'
}"
x-text="vendor.last_sync_status"
></span>
<p class="text-xs text-gray-500 mt-1" x-text="formatDate(vendor.last_sync_at)"></p>
</div>
<span x-show="!vendor.last_sync_at" class="text-gray-400">Never</span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 text-xs rounded-full bg-orange-100 text-orange-700"
x-show="vendor.pending_orders > 0"
x-text="vendor.pending_orders + ' pending'"
></span>
<span class="text-gray-500" x-text="vendor.total_orders + ' total'"></span>
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="openConfigModal(vendor)"
class="flex items-center justify-center px-2 py-1 text-sm text-purple-600 transition-colors duration-150 rounded-md hover:bg-purple-100 dark:hover:bg-purple-900"
title="Configure"
>
<span x-html="$icon('cog', 'w-4 h-4')"></span>
</button>
<button
x-show="vendor.is_configured"
@click="testConnection(vendor)"
class="flex items-center justify-center px-2 py-1 text-sm text-blue-600 transition-colors duration-150 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900"
title="Test Connection"
>
<span x-html="$icon('lightning-bolt', 'w-4 h-4')"></span>
</button>
<button
x-show="vendor.is_configured"
@click="triggerSync(vendor)"
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
title="Trigger Sync"
>
<span x-html="$icon('download', 'w-4 h-4')"></span>
</button>
<button
x-show="vendor.total_orders > 0"
@click="viewOrders(vendor)"
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Orders"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
<!-- Pagination -->
<div x-show="totalVendors > limit" class="mt-4 grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border dark:border-gray-700 rounded-lg bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing <span x-text="((page - 1) * limit) + 1"></span>-<span x-text="Math.min(page * limit, totalVendors)"></span> of <span x-text="totalVendors"></span>
</span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav>
<ul class="inline-flex items-center">
<li>
<button @click="page--; loadVendors()" :disabled="page <= 1" class="px-3 py-1 rounded-md disabled:opacity-50">
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<li>
<button @click="page++; loadVendors()" :disabled="page * limit >= totalVendors" class="px-3 py-1 rounded-md disabled:opacity-50">
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</li>
</ul>
</nav>
</span>
</div>
<!-- Configuration Modal -->
{% call modal('configModal', 'Configure Letzshop', 'showConfigModal', size='md') %}
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Configuring: <span class="font-semibold" x-text="selectedVendor?.vendor_name"></span>
</p>
<form @submit.prevent="saveVendorConfig()">
<div class="space-y-4 mb-6">
<!-- API Key -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
API Key <span x-show="!vendorCredentials" class="text-red-500">*</span>
</label>
<div class="relative">
<input
:type="showApiKey ? 'text' : 'password'"
x-model="configForm.api_key"
:placeholder="vendorCredentials ? vendorCredentials.api_key_masked : 'Enter API key'"
class="block w-full px-3 py-2 pr-10 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<button type="button" @click="showApiKey = !showApiKey" class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">
<span x-html="$icon(showApiKey ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
</button>
</div>
</div>
<!-- Auto Sync -->
<div>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="configForm.auto_sync_enabled"
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Enable Auto-Sync</span>
</label>
</div>
<!-- Sync Interval -->
<div x-show="configForm.auto_sync_enabled">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Sync Interval
</label>
<select
x-model="configForm.sync_interval_minutes"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="15">Every 15 minutes</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every hour</option>
<option value="120">Every 2 hours</option>
</select>
</div>
</div>
<div class="flex justify-between">
<button
type="button"
x-show="vendorCredentials"
@click="deleteVendorConfig()"
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
>
Remove
</button>
<div class="flex gap-3 ml-auto">
<button
type="button"
@click="showConfigModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
:disabled="savingConfig"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="savingConfig" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="savingConfig ? 'Saving...' : 'Save'"></span>
</button>
</div>
</div>
</form>
{% endcall %}
<!-- Orders Modal -->
{% call modal('ordersModal', 'Vendor Orders', 'showOrdersModal', size='xl') %}
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Orders for: <span class="font-semibold" x-text="selectedVendor?.vendor_name"></span>
</p>
<div x-show="loadingOrders" class="py-8 text-center">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto')"></span>
</div>
<div x-show="!loadingOrders">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b dark:border-gray-700">
<th class="pb-2">Order</th>
<th class="pb-2">Customer</th>
<th class="pb-2">Total</th>
<th class="pb-2">Status</th>
<th class="pb-2">Date</th>
</tr>
</thead>
<tbody class="divide-y dark:divide-gray-700">
<template x-for="order in vendorOrders" :key="order.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="py-2" x-text="order.letzshop_order_number || order.letzshop_order_id"></td>
<td class="py-2" x-text="order.customer_email || 'N/A'"></td>
<td class="py-2" x-text="order.total_amount ? order.total_amount + ' ' + order.currency : 'N/A'"></td>
<td class="py-2">
<span
class="px-2 py-0.5 text-xs rounded-full"
:class="{
'bg-orange-100 text-orange-700': order.sync_status === 'pending',
'bg-green-100 text-green-700': order.sync_status === 'confirmed',
'bg-red-100 text-red-700': order.sync_status === 'rejected',
'bg-blue-100 text-blue-700': order.sync_status === 'shipped'
}"
x-text="order.sync_status"
></span>
</td>
<td class="py-2" x-text="formatDate(order.created_at)"></td>
</tr>
</template>
</tbody>
</table>
<p x-show="vendorOrders.length === 0" class="py-4 text-center text-gray-500">No orders found</p>
</div>
{% endcall %}
{% endblock %}

View File

@@ -0,0 +1,603 @@
{# app/templates/admin/marketplace-letzshop.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/tabs.html' import tabs_nav, tab_button, tab_panel, endtab_panel %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{# Import modals macro - custom modals below use inline definition for specialized forms #}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Letzshop Management{% endblock %}
{% block alpine_data %}adminMarketplaceLetzshop(){% endblock %}
{% block extra_head %}
<!-- Tom Select CSS with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
/>
<style>
/* Tom Select dark mode overrides */
.dark .ts-wrapper .ts-control {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input {
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input::placeholder {
color: rgb(156 163 175);
}
.dark .ts-dropdown {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-dropdown .option {
color: rgb(209 213 219);
}
.dark .ts-dropdown .option.active {
background-color: rgb(147 51 234);
color: white;
}
.dark .ts-dropdown .option:hover {
background-color: rgb(75 85 99);
}
.dark .ts-wrapper.focus .ts-control {
border-color: rgb(147 51 234);
box-shadow: 0 0 0 1px rgb(147 51 234);
}
</style>
{% endblock %}
{% block content %}
<!-- Page Header with Vendor Selector -->
{% call page_header_flex(title='Letzshop Management', subtitle='Manage Letzshop integration for vendors') %}
<div class="flex items-center gap-4">
<!-- Vendor Autocomplete (Tom Select) -->
<div class="w-80">
<select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
</select>
</div>
{{ refresh_button(loading_var='loading', onclick='refreshData()', variant='secondary') }}
</div>
{% endcall %}
<!-- Success Message -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold" x-text="successMessage"></p>
</div>
<button @click="successMessage = ''" class="ml-auto text-green-700 dark:text-green-300 hover:text-green-900">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
<!-- Error Message -->
{{ error_state('Error', show_condition='error && !loading') }}
<!-- Cross-vendor info banner (shown when no vendor selected) -->
<div x-show="!selectedVendor && !loading" class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-center">
<span x-html="$icon('information-circle', 'w-6 h-6 text-blue-500 mr-3')"></span>
<div>
<h3 class="font-medium text-blue-800 dark:text-blue-200">All Vendors View</h3>
<p class="text-sm text-blue-700 dark:text-blue-300">Showing data across all vendors. Select a vendor above to manage products, import orders, or access settings.</p>
</div>
</div>
</div>
<!-- Main Content -->
<div x-show="!loading" x-transition x-cloak>
<!-- Selected Vendor Filter (same pattern as orders page) -->
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
</div>
<div class="flex items-center gap-3">
<div>
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
</div>
<!-- Status badges -->
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="letzshopStatus.is_configured ? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'">
</span>
<span x-show="letzshopStatus.auto_sync_enabled" class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
Auto-sync
</span>
</div>
</div>
<button @click="clearVendorSelection()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
<span x-html="$icon('x', 'w-4 h-4')"></span>
Clear filter
</button>
</div>
</div>
<!-- Tabs -->
{% call tabs_nav(tab_var='activeTab') %}
{{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }}
{{ tab_button('orders', 'Orders', tab_var='activeTab', icon='shopping-cart', count_var='orderStats.pending') }}
{{ tab_button('exceptions', 'Exceptions', tab_var='activeTab', icon='exclamation-circle', count_var='exceptionStats.pending') }}
{{ tab_button('jobs', 'Jobs', tab_var='activeTab', icon='collection') }}
<template x-if="selectedVendor">
<span>{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }}</span>
</template>
{% endcall %}
<!-- Products Tab -->
{{ tab_panel('products', tab_var='activeTab') }}
{% include 'marketplace/admin/partials/letzshop-products-tab.html' %}
{{ endtab_panel() }}
<!-- Orders Tab -->
{{ tab_panel('orders', tab_var='activeTab') }}
{% include 'marketplace/admin/partials/letzshop-orders-tab.html' %}
{{ endtab_panel() }}
<!-- Settings Tab - Vendor only -->
<template x-if="selectedVendor">
{{ tab_panel('settings', tab_var='activeTab') }}
{% include 'marketplace/admin/partials/letzshop-settings-tab.html' %}
{{ endtab_panel() }}
</template>
<!-- Exceptions Tab -->
{{ tab_panel('exceptions', tab_var='activeTab') }}
{% include 'marketplace/admin/partials/letzshop-exceptions-tab.html' %}
{{ endtab_panel() }}
<!-- Jobs Tab -->
{{ tab_panel('jobs', tab_var='activeTab') }}
{% include 'marketplace/admin/partials/letzshop-jobs-table.html' %}
{{ endtab_panel() }}
</div>
<!-- Tracking Modal -->
<div
x-show="showTrackingModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showTrackingModal = false"
x-cloak
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-md"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Set Tracking Information</h3>
<button @click="showTrackingModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<form @submit.prevent="submitTracking()">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Tracking Number <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="trackingForm.tracking_number"
required
placeholder="1Z999AA10123456784"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Carrier <span class="text-red-500">*</span>
</label>
<select
x-model="trackingForm.tracking_provider"
required
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">Select carrier...</option>
<option value="dhl">DHL</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="post_lu">Post Luxembourg</option>
<option value="dpd">DPD</option>
<option value="gls">GLS</option>
<option value="other">Other</option>
</select>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
@click="showTrackingModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
:disabled="submittingTracking"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="submittingTracking" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="submittingTracking ? 'Saving...' : 'Save Tracking'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Order Details Modal -->
<div
x-show="showOrderModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showOrderModal = false"
x-cloak
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl max-h-[80vh] overflow-y-auto"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Order Details</h3>
<a
:href="'/admin/letzshop/orders/' + selectedOrder?.id"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 flex items-center gap-1"
title="Open full order page"
>
<span x-html="$icon('external-link', 'w-3 h-3')"></span>
Full View
</a>
</div>
<button @click="showOrderModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div x-show="selectedOrder" class="space-y-4">
<!-- Order Info Grid -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Order Number:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.external_order_number || selectedOrder?.order_number"></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Order Date:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="formatDate(selectedOrder?.order_date || selectedOrder?.created_at)"></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Status:</span>
<span
class="ml-2 px-2 py-0.5 text-xs rounded-full"
:class="{
'bg-orange-100 text-orange-700': selectedOrder?.status === 'pending',
'bg-green-100 text-green-700': selectedOrder?.status === 'processing',
'bg-red-100 text-red-700': selectedOrder?.status === 'cancelled',
'bg-blue-100 text-blue-700': selectedOrder?.status === 'shipped'
}"
x-text="selectedOrder?.status === 'cancelled' ? 'DECLINED' : (selectedOrder?.status === 'processing' ? 'CONFIRMED' : selectedOrder?.status?.toUpperCase())"
></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Total:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.total_amount + ' ' + selectedOrder?.currency"></span>
</div>
</div>
<!-- Customer & Shipping Info -->
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<span x-html="$icon('user', 'w-4 h-4')"></span>
Customer & Shipping
</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Name:</span>
<span class="ml-1" x-text="selectedOrder?.customer_name || 'N/A'"></span>
</p>
<p class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Email:</span>
<a :href="'mailto:' + selectedOrder?.customer_email" class="ml-1 text-purple-600 hover:underline" x-text="selectedOrder?.customer_email"></a>
</p>
<p class="text-gray-600 dark:text-gray-400" x-show="selectedOrder?.customer_locale">
<span class="font-medium">Language:</span>
<span class="ml-1 uppercase" x-text="selectedOrder?.customer_locale"></span>
</p>
</div>
<div x-show="selectedOrder?.shipping_country_iso">
<p class="text-gray-600 dark:text-gray-400">
<span class="font-medium">Ship to:</span>
<span class="ml-1" x-text="selectedOrder?.shipping_country_iso"></span>
</p>
</div>
</div>
</div>
<!-- Tracking Info -->
<div x-show="selectedOrder?.tracking_number" class="border-t border-gray-200 dark:border-gray-600 pt-4">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<span x-html="$icon('truck', 'w-4 h-4')"></span>
Tracking
</h4>
<div class="text-sm bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<p class="text-gray-700 dark:text-gray-300">
<span class="font-medium">Carrier:</span>
<span class="ml-1" x-text="selectedOrder?.tracking_provider"></span>
</p>
<p class="text-gray-700 dark:text-gray-300">
<span class="font-medium">Tracking #:</span>
<span class="ml-1 font-mono" x-text="selectedOrder?.tracking_number"></span>
</p>
</div>
</div>
<!-- Order Items -->
<div x-show="selectedOrder?.items?.length > 0" class="border-t border-gray-200 dark:border-gray-600 pt-4">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<span x-html="$icon('shopping-bag', 'w-4 h-4')"></span>
Items
<span class="text-xs font-normal text-gray-500">
(<span x-text="selectedOrder?.items?.length"></span> item<span x-show="selectedOrder?.items?.length > 1">s</span>)
</span>
</h4>
<div class="space-y-2 max-h-64 overflow-y-auto">
<template x-for="(item, index) in selectedOrder?.items || []" :key="item.id">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="font-medium text-gray-700 dark:text-gray-200 text-sm" x-text="item.product_name || 'Unknown Product'"></p>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
<p x-show="item.gtin">
<span class="font-medium">GTIN:</span> <span x-text="item.gtin"></span>
<span x-show="item.gtin_type" class="text-gray-400" x-text="'(' + item.gtin_type + ')'"></span>
</p>
<p x-show="item.product_sku"><span class="font-medium">SKU:</span> <span x-text="item.product_sku"></span></p>
<p><span class="font-medium">Qty:</span> <span x-text="item.quantity"></span></p>
<p x-show="item.unit_price"><span class="font-medium">Price:</span> <span x-text="item.unit_price + ' ' + selectedOrder?.currency"></span></p>
</div>
</div>
<div class="flex items-center gap-2 ml-4">
<!-- Item State Badge -->
<span
class="px-2 py-0.5 text-xs rounded-full whitespace-nowrap"
:class="{
'bg-orange-100 text-orange-700': !item.item_state,
'bg-green-100 text-green-700': item.item_state === 'confirmed_available',
'bg-red-100 text-red-700': item.item_state === 'confirmed_unavailable',
'bg-gray-100 text-gray-700': item.item_state === 'returned'
}"
x-text="item.item_state === 'confirmed_unavailable' ? 'DECLINED' : (item.item_state === 'confirmed_available' ? 'CONFIRMED' : (item.item_state ? item.item_state.toUpperCase() : 'PENDING'))"
></span>
<!-- Item Actions (only for unconfirmed items) -->
<template x-if="!item.item_state && selectedOrder?.status === 'pending'">
<div class="flex gap-1">
<button
@click="confirmInventoryUnit(selectedOrder, item, index)"
class="p-1 text-green-600 hover:bg-green-100 rounded"
title="Confirm this item"
>
<span x-html="$icon('check', 'w-4 h-4')"></span>
</button>
<button
@click="declineInventoryUnit(selectedOrder, item, index)"
class="p-1 text-red-600 hover:bg-red-100 rounded"
title="Decline this item"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
<!-- Bulk Actions -->
<div x-show="selectedOrder?.status === 'pending'" class="mt-4 flex gap-2 justify-end">
<button
@click="confirmAllItems(selectedOrder)"
class="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg"
>
Confirm All Items
</button>
<button
@click="declineAllItems(selectedOrder)"
class="px-3 py-1.5 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg"
>
Decline All Items
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Exception Resolve Modal -->
<div
x-show="showResolveModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showResolveModal = false"
x-cloak
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-lg"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Resolve Exception</h3>
<button @click="showResolveModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<!-- Exception Details -->
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="selectedExceptionForResolve?.original_product_name || 'Unknown Product'"></p>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p x-show="selectedExceptionForResolve?.original_gtin">
<span class="font-medium">GTIN:</span>
<code class="ml-1 px-1 bg-gray-200 dark:bg-gray-600 rounded" x-text="selectedExceptionForResolve?.original_gtin"></code>
</p>
<p x-show="selectedExceptionForResolve?.original_sku">
<span class="font-medium">SKU:</span> <span x-text="selectedExceptionForResolve?.original_sku"></span>
</p>
<p>
<span class="font-medium">Order:</span>
<a :href="'/admin/orders/' + selectedExceptionForResolve?.order_id" class="text-purple-600 hover:underline" x-text="selectedExceptionForResolve?.order_number"></a>
</p>
</div>
</div>
<form @submit.prevent="submitResolveException()">
<!-- Product Search -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Assign Product <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
type="text"
x-model="productSearchQuery"
@input.debounce.300ms="searchProducts()"
placeholder="Search by name, SKU, or GTIN..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<span x-show="searchingProducts" x-html="$icon('spinner', 'w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
</div>
<!-- Search Results -->
<div x-show="productSearchResults.length > 0" class="mt-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-md">
<template x-for="product in productSearchResults" :key="product.id">
<button
type="button"
@click="selectProductForResolve(product)"
class="w-full px-3 py-2 text-left text-sm hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-gray-100 dark:border-gray-700 last:border-b-0"
:class="resolveForm.product_id === product.id ? 'bg-purple-100 dark:bg-purple-900/30' : ''"
>
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="product.name || product.title"></p>
<p class="text-xs text-gray-500">
<span x-show="product.gtin" x-text="'GTIN: ' + product.gtin"></span>
<span x-show="product.gtin && product.sku"> · </span>
<span x-show="product.sku" x-text="'SKU: ' + product.sku"></span>
</p>
</button>
</template>
</div>
<!-- Selected Product -->
<div x-show="resolveForm.product_id" class="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded-md flex items-center justify-between">
<div>
<p class="text-sm font-medium text-green-700 dark:text-green-300" x-text="resolveForm.product_name"></p>
<p class="text-xs text-green-600 dark:text-green-400" x-text="'Product ID: ' + resolveForm.product_id"></p>
</div>
<button type="button" @click="resolveForm.product_id = null; resolveForm.product_name = ''" class="text-green-600 hover:text-green-800">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</div>
<!-- Resolution Notes -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Notes (Optional)
</label>
<textarea
x-model="resolveForm.notes"
rows="2"
placeholder="Add any notes about this resolution..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
></textarea>
</div>
<!-- Bulk Resolve Option -->
<div x-show="selectedExceptionForResolve?.original_gtin" class="mb-4">
<label class="flex items-center text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
x-model="resolveForm.bulk_resolve"
class="mr-2 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
Resolve all pending exceptions with this GTIN
</label>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
@click="showResolveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
:disabled="!resolveForm.product_id || submittingResolve"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50"
>
<span x-show="submittingResolve" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="submittingResolve ? 'Resolving...' : 'Resolve Exception'"></span>
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<!-- Tom Select JS with local fallback -->
<script>
(function() {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
script.onerror = function() {
console.warn('Tom Select CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace-letzshop.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,392 @@
{# app/templates/admin/marketplace-product-detail.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Marketplace Product Details{% endblock %}
{% block alpine_data %}adminMarketplaceProductDetail(){% endblock %}
{% block content %}
{% call detail_page_header("product?.title || 'Product Details'", back_url, subtitle_show='product') %}
<span x-text="product?.marketplace || 'Unknown'"></span>
<span class="text-gray-400 mx-2">|</span>
<span x-text="'ID: ' + productId"></span>
{% endcall %}
{{ loading_state('Loading product details...') }}
{{ error_state('Error loading product') }}
<!-- Product Details -->
<div x-show="!loading && product">
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<button
@click="openCopyModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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">
<span x-html="$icon('duplicate', 'w-4 h-4 mr-2')"></span>
Copy to Vendor Catalog
</button>
<a
x-show="product?.source_url"
:href="product?.source_url"
target="_blank"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
View Source
</a>
</div>
</div>
<!-- Product Header with Image -->
<div class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Product Image -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden">
<template x-if="product?.image_link">
<img :src="product?.image_link" :alt="product?.title" class="w-full h-full object-contain" />
</template>
<template x-if="!product?.image_link">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-16 h-16 text-gray-300')"></span>
</div>
</template>
</div>
<!-- Additional Images -->
<div x-show="product?.additional_images?.length > 0" class="mt-4">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Additional Images</p>
<div class="grid grid-cols-4 gap-2">
<template x-for="(img, index) in (product?.additional_images || [])" :key="index">
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded overflow-hidden">
<img :src="img" :alt="'Image ' + (index + 1)" class="w-full h-full object-cover" />
</div>
</template>
</div>
</div>
</div>
<!-- Product Info -->
<div class="md:col-span-2 space-y-6">
<!-- Basic Info Card -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Information
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Brand</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.brand || 'No brand'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product Type</p>
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="product?.is_digital ? 'text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400' : 'text-orange-700 bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400'"
x-text="product?.is_digital ? 'Digital' : 'Physical'">
</span>
<span x-show="product?.product_type_enum" class="text-xs text-gray-500" x-text="'(' + product?.product_type_enum + ')'"></span>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Condition</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.condition || 'Not specified'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Status</p>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="product?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-900/30 dark:text-green-400' : 'text-red-700 bg-red-100 dark:bg-red-900/30 dark:text-red-400'"
x-text="product?.is_active ? 'Active' : 'Inactive'">
</span>
</div>
</div>
</div>
<!-- Pricing Card -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing
</h3>
<div class="grid gap-4 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Price</p>
<p class="text-lg font-bold text-gray-700 dark:text-gray-200" x-text="formatPrice(product?.price_numeric, product?.currency)">-</p>
</div>
<div x-show="product?.sale_price_numeric">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Sale Price</p>
<p class="text-lg font-bold text-green-600 dark:text-green-400" x-text="formatPrice(product?.sale_price_numeric, product?.currency)">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Availability</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.availability || 'Not specified'">-</p>
</div>
</div>
</div>
</div>
</div>
<!-- Identifiers Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Identifiers
</h3>
<div class="grid gap-4 md:grid-cols-4">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace ID</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.marketplace_product_id || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">GTIN/EAN</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.gtin || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">MPN</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.mpn || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">SKU</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.sku || '-'">-</p>
</div>
</div>
</div>
<!-- Source Information Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Source Information
</h3>
<div class="grid gap-4 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.marketplace || 'Unknown'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source Vendor</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.vendor_name || 'Unknown'">-</p>
</div>
<div x-show="product?.platform">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Platform</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.platform">-</p>
</div>
</div>
<div x-show="product?.source_url" class="mt-4">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Source URL</p>
<a :href="product?.source_url" target="_blank" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 break-all" x-text="product?.source_url">-</a>
</div>
</div>
<!-- Category Information -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.google_product_category || product?.category_path">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Categories
</h3>
<div class="space-y-3">
<div x-show="product?.google_product_category">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Google Product Category</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.google_product_category">-</p>
</div>
<div x-show="product?.category_path">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Category Path</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.category_path">-</p>
</div>
</div>
</div>
<!-- Physical Attributes -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.color || product?.size || product?.weight">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Physical Attributes
</h3>
<div class="grid gap-4 md:grid-cols-3">
<div x-show="product?.color">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Color</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.color">-</p>
</div>
<div x-show="product?.size">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Size</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.size">-</p>
</div>
<div x-show="product?.weight">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Weight</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
<span x-text="product?.weight"></span>
<span x-text="product?.weight_unit || ''"></span>
</p>
</div>
</div>
</div>
<!-- Translations Card with Tabs -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.translations && Object.keys(product.translations).length > 0" x-data="{ activeTab: Object.keys(product?.translations || {})[0] || '' }">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Translations
</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="Object.keys(product?.translations || {}).length"></span> language(s)
</span>
</div>
<!-- Language Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Translation tabs">
<template x-for="(trans, lang) in (product?.translations || {})" :key="lang">
<button
@click="activeTab = lang"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': activeTab === lang,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': activeTab !== lang
}"
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors"
>
<span class="uppercase" x-text="lang"></span>
<span class="ml-1 text-xs text-gray-400" x-text="getLanguageName(lang)"></span>
</button>
</template>
</nav>
</div>
<!-- Tab Content -->
<template x-for="(trans, lang) in (product?.translations || {})" :key="'content-' + lang">
<div x-show="activeTab === lang" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="space-y-4">
<!-- Title -->
<div class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Title</p>
<button
@click="copyToClipboard(trans?.title)"
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Copy to clipboard"
>
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
</button>
</div>
<p class="text-base font-medium text-gray-900 dark:text-gray-100" x-text="trans?.title || 'No title'"></p>
</div>
<!-- Short Description -->
<div x-show="trans?.short_description" class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Short Description</p>
<button
@click="copyToClipboard(trans?.short_description)"
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Copy to clipboard"
>
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
</button>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="trans?.short_description"></p>
</div>
<!-- Description -->
<div x-show="trans?.description" class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Description</p>
<button
@click="copyToClipboard(trans?.description)"
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Copy to clipboard"
>
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
</button>
</div>
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none max-h-96 overflow-y-auto" x-html="trans?.description || 'No description'"></div>
</div>
<!-- Empty state if no content -->
<div x-show="!trans?.title && !trans?.short_description && !trans?.description" class="text-center py-8">
<span x-html="$icon('document-text', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No translation content for this language</p>
</div>
</div>
</div>
</template>
</div>
<!-- Timestamps -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Record Information
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Created At</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.created_at)">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Last Updated</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.updated_at)">-</p>
</div>
</div>
</div>
</div>
<!-- Copy to Vendor Modal -->
{% call modal_simple('copyToVendorModal', 'Copy to Vendor Catalog', show_var='showCopyModal', size='md') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Copy this product to a vendor's catalog.
</p>
<!-- Target Vendor Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Target Vendor <span class="text-red-500">*</span>
</label>
<select
x-model="copyForm.vendor_id"
class="w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">Select a vendor...</option>
<template x-for="vendor in targetVendors" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name + ' (' + vendor.vendor_code + ')'"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
The product will be copied to this vendor's catalog
</p>
</div>
<!-- Options -->
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
x-model="copyForm.skip_existing"
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Skip if already exists in catalog</span>
</label>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showCopyModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
@click="executeCopyToVendor()"
:disabled="!copyForm.vendor_id || copying"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="copying" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="copying ? 'Copying...' : 'Copy Product'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace-product-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,476 @@
{# app/templates/admin/marketplace-products.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Marketplace Products{% endblock %}
{% block alpine_data %}adminMarketplaceProducts(){% endblock %}
{% block extra_head %}
<!-- Tom Select CSS with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
/>
<style>
/* Tom Select dark mode overrides */
.dark .ts-wrapper .ts-control {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input {
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input::placeholder {
color: rgb(156 163 175);
}
.dark .ts-dropdown {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-dropdown .option {
color: rgb(209 213 219);
}
.dark .ts-dropdown .option.active {
background-color: rgb(147 51 234);
color: white;
}
.dark .ts-dropdown .option:hover {
background-color: rgb(75 85 99);
}
.dark .ts-wrapper.focus .ts-control {
border-color: rgb(147 51 234);
box-shadow: 0 0 0 1px rgb(147 51 234);
}
</style>
{% endblock %}
{% block content %}
<!-- Page Header with Vendor Selector -->
{% call page_header_flex(title='Marketplace Products', subtitle='Master product repository - Browse all imported products from external sources') %}
<div class="flex items-center gap-4">
<!-- Vendor Autocomplete (Tom Select) -->
<div class="w-80">
<select id="vendor-select" x-ref="vendorSelect" placeholder="Filter by vendor...">
</select>
</div>
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
</div>
{% endcall %}
<!-- Selected Vendor Info -->
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
</div>
<div>
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
</div>
</div>
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
<span x-html="$icon('x', 'w-4 h-4')"></span>
Clear filter
</button>
</div>
</div>
{{ loading_state('Loading products...') }}
{{ error_state('Error loading products') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-5">
<!-- Card: Total Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Products
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total || 0">
0
</p>
</div>
</div>
<!-- Card: Active Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active || 0">
0
</p>
</div>
</div>
<!-- Card: Inactive Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Inactive
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.inactive || 0">
0
</p>
</div>
</div>
<!-- Card: Digital Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('code', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Digital
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.digital || 0">
0
</p>
</div>
</div>
<!-- Card: Physical Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('truck', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Physical
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.physical || 0">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-xl">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search by title, GTIN, SKU, or brand..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Marketplace Filter -->
<select
x-model="filters.marketplace"
@change="pagination.page = 1; loadProducts()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Marketplaces</option>
<template x-for="mp in marketplaces" :key="mp">
<option :value="mp" x-text="mp"></option>
</template>
</select>
<!-- Product Type Filter -->
<select
x-model="filters.is_digital"
@change="pagination.page = 1; loadProducts()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Types</option>
<option value="false">Physical</option>
<option value="true">Digital</option>
</select>
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="pagination.page = 1; loadProducts()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
</div>
<!-- Bulk Actions Bar (shown when items selected) -->
<div x-show="!loading && selectedProducts.length > 0"
x-transition
class="mb-4 p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
<span x-text="selectedProducts.length"></span> product(s) selected
</span>
<button
@click="clearSelection()"
class="text-sm text-purple-600 dark:text-purple-400 hover:underline"
>
Clear selection
</button>
</div>
<div class="flex items-center gap-2">
<button
@click="openCopyToVendorModal()"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
<span x-html="$icon('duplicate', 'w-4 h-4 mr-2')"></span>
Copy to Vendor Catalog
</button>
</div>
</div>
</div>
<!-- Products Table with Pagination -->
<div x-show="!loading">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3 w-10">
<input
type="checkbox"
@change="toggleSelectAll($event)"
:checked="products.length > 0 && selectedProducts.length === products.length"
:indeterminate="selectedProducts.length > 0 && selectedProducts.length < products.length"
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
/>
</th>
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">Identifiers</th>
<th class="px-4 py-3">Source</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="products.length === 0">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('database', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No marketplace products found</p>
<p class="text-xs mt-1" x-text="filters.search || filters.marketplace || filters.is_active || selectedVendor ? 'Try adjusting your search or filters' : 'Import products from the Import page'"></p>
</div>
</td>
</tr>
</template>
<!-- Product Rows -->
<template x-for="product in products" :key="product.id">
<tr class="text-gray-700 dark:text-gray-400" :class="isSelected(product.id) && 'bg-purple-50 dark:bg-purple-900/10'">
<!-- Checkbox -->
<td class="px-4 py-3">
<input
type="checkbox"
:checked="isSelected(product.id)"
@change="toggleSelection(product.id)"
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
/>
</td>
<!-- Product Info -->
<td class="px-4 py-3">
<div class="flex items-center">
<!-- Product Image -->
<div class="w-12 h-12 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
<template x-if="product.image_link">
<img :src="product.image_link" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
</template>
<template x-if="!product.image_link">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-6 h-6 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Product Details -->
<div class="min-w-0">
<a :href="'/admin/marketplace-products/' + product.id" class="font-semibold text-sm truncate max-w-xs hover:text-purple-600 dark:hover:text-purple-400" x-text="product.title || 'Untitled'"></a>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
<template x-if="product.is_digital">
<span class="inline-flex items-center px-2 py-0.5 mt-1 text-xs font-medium text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 rounded">
<span x-html="$icon('code', 'w-3 h-3 mr-1')"></span>
Digital
</span>
</template>
</div>
</div>
</td>
<!-- Identifiers -->
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<template x-if="product.gtin">
<p class="text-xs"><span class="text-gray-500">GTIN:</span> <span x-text="product.gtin" class="font-mono"></span></p>
</template>
<template x-if="product.sku">
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.sku" class="font-mono"></span></p>
</template>
<template x-if="!product.gtin && !product.sku">
<p class="text-xs text-gray-400">No identifiers</p>
</template>
</div>
</td>
<!-- Source (Marketplace & Vendor) -->
<td class="px-4 py-3 text-sm">
<p class="font-medium" x-text="product.marketplace || 'Unknown'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[150px]" x-text="'from ' + (product.vendor_name || 'Unknown')"></p>
</td>
<!-- Price -->
<td class="px-4 py-3 text-sm">
<template x-if="product.price_numeric">
<p class="font-medium" x-text="formatPrice(product.price_numeric, product.currency)"></p>
</template>
<template x-if="!product.price_numeric">
<p class="text-gray-400">-</p>
</template>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="product.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="product.is_active ? 'Active' : 'Inactive'">
</span>
<template x-if="product.availability">
<p class="text-xs text-gray-500 mt-1" x-text="product.availability"></p>
</template>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<a
:href="'/admin/marketplace-products/' + product.id"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</a>
<button
@click="copySingleProduct(product.id)"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-green-600 rounded-lg dark:text-green-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Copy to Vendor Catalog"
>
<span x-html="$icon('duplicate', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination(show_condition="!loading && pagination.total > 0") }}
</div>
<!-- Copy to Vendor Modal -->
{% call modal_simple('copyToVendorModal', 'Copy to Vendor Catalog', show_var='showCopyModal', size='md') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Copy <span class="font-medium" x-text="selectedProducts.length"></span> selected product(s) to a vendor catalog.
</p>
<!-- Target Vendor Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Target Vendor <span class="text-red-500">*</span>
</label>
<select
x-model="copyForm.vendor_id"
class="w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">Select a vendor...</option>
<template x-for="vendor in targetVendors" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name + ' (' + vendor.vendor_code + ')'"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Products will be copied to this vendor's catalog
</p>
</div>
<!-- Options -->
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
x-model="copyForm.skip_existing"
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Skip products that already exist in catalog</span>
</label>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showCopyModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
@click="executeCopyToVendor()"
:disabled="!copyForm.vendor_id || copying"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="copying" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="copying ? 'Copying...' : 'Copy Products'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<!-- Tom Select JS with local fallback -->
<script>
(function() {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
script.onerror = function() {
console.warn('Tom Select CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace-products.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,351 @@
{# app/templates/admin/marketplace.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/modals.html' import job_details_modal %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% from 'shared/macros/tabs.html' import tabs_nav, tab_button, tab_panel, endtab_panel %}
{% block title %}Marketplace Import{% endblock %}
{% block alpine_data %}adminMarketplace(){% endblock %}
{% block content %}
{% call page_header_flex(title='Marketplace Import', subtitle='Import products from external marketplaces') %}
{{ refresh_button(onclick='refreshJobs()') }}
{% endcall %}
{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Import Form Card with Tabs -->
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Start New Import
</h3>
<!-- Marketplace Tabs -->
{% call tabs_nav(tab_var='activeImportTab') %}
{{ tab_button('letzshop', 'Letzshop', tab_var='activeImportTab', icon='shopping-cart', onclick="switchMarketplace('letzshop')") }}
{{ tab_button('codeswholesale', 'CodesWholesale', tab_var='activeImportTab', icon='code', onclick="switchMarketplace('codeswholesale')") }}
{% endcall %}
<!-- Letzshop Import Form -->
{{ tab_panel('letzshop', tab_var='activeImportTab') }}
<form @submit.prevent="startImport()">
<div class="grid gap-6 mb-4 md:grid-cols-2">
<!-- Vendor Selection -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Vendor <span class="text-red-500">*</span>
</label>
<select
x-model="importForm.vendor_id"
@change="onVendorChange()"
required
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
>
<option value="">Select a vendor...</option>
<template x-for="vendor in vendors" :key="vendor.id">
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
</template>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Select the vendor to import products for
</p>
</div>
<!-- CSV URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
CSV URL <span class="text-red-500">*</span>
</label>
<input
x-model="importForm.csv_url"
type="url"
required
placeholder="https://example.com/products.csv"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter the URL of the Letzshop CSV feed
</p>
</div>
<!-- Language Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Language
</label>
<select
x-model="importForm.language"
@change="onLanguageChange()"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
>
<option value="fr">French (FR)</option>
<option value="en">English (EN)</option>
<option value="de">German (DE)</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Select the language of the CSV feed
</p>
</div>
<!-- Batch Size -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Batch Size
</label>
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Number of products to process per batch (100-5000)
</p>
</div>
</div>
<!-- Quick Fill Buttons (when vendor is selected) -->
<div class="mb-4" x-show="importForm.vendor_id && selectedVendor">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Quick Fill (from vendor settings)
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="quickFill('fr')"
x-show="selectedVendor?.letzshop_csv_url_fr"
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
>
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
French CSV
</button>
<button
type="button"
@click="quickFill('en')"
x-show="selectedVendor?.letzshop_csv_url_en"
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
>
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
English CSV
</button>
<button
type="button"
@click="quickFill('de')"
x-show="selectedVendor?.letzshop_csv_url_de"
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
>
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
German CSV
</button>
</div>
<p class="mt-1 text-xs text-red-600 dark:text-red-400" x-show="!selectedVendor?.letzshop_csv_url_fr && !selectedVendor?.letzshop_csv_url_en && !selectedVendor?.letzshop_csv_url_de">
This vendor has no Letzshop CSV URLs configured
</p>
</div>
<!-- Submit Button -->
<div class="flex items-center justify-end">
<button
type="submit"
:disabled="importing || !importForm.csv_url || !importForm.vendor_id"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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 disabled:cursor-not-allowed"
>
<span x-show="!importing" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
</button>
</div>
</form>
{{ endtab_panel() }}
<!-- CodesWholesale Import Form -->
{{ tab_panel('codeswholesale', tab_var='activeImportTab') }}
<div class="text-center py-12">
<span x-html="$icon('code', 'inline w-16 h-16 text-gray-300 dark:text-gray-600 mb-4')"></span>
<h4 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">
CodesWholesale Integration
</h4>
<p class="text-gray-500 dark:text-gray-400 mb-4">
Import digital game keys and software licenses from CodesWholesale API.
</p>
<p class="text-sm text-gray-400 dark:text-gray-500">
Coming soon - This feature is under development
</p>
</div>
{{ endtab_panel() }}
</div>
</div>
<!-- Filters -->
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
<div class="grid gap-4 md:grid-cols-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
Filter by Vendor
</label>
<select
x-model="filters.vendor_id"
@change="loadJobs()"
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Vendors</option>
<template x-for="vendor in vendors" :key="vendor.id">
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
</template>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
Filter by Status
</label>
<select
x-model="filters.status"
@change="loadJobs()"
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="completed_with_errors">Completed with Errors</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
Filter by Marketplace
</label>
<select
x-model="filters.marketplace"
@change="loadJobs()"
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Marketplaces</option>
<option value="Letzshop">Letzshop</option>
<option value="CodesWholesale">CodesWholesale</option>
</select>
</div>
<div class="flex items-end">
<button
@click="clearFilters()"
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
>
Clear Filters
</button>
</div>
</div>
</div>
<!-- Import Jobs List -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
My Import Jobs
</h3>
<a href="/admin/imports" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300">
View all system imports →
</a>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
</div>
<!-- Empty State -->
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
<p class="text-gray-600 dark:text-gray-400">You haven't triggered any imports yet</p>
<p class="text-sm text-gray-500 dark:text-gray-500">Start a new import using the form above</p>
</div>
<!-- Jobs Table -->
<div x-show="!loading && jobs.length > 0">
{% call table_wrapper() %}
{{ table_header(['Job ID', 'Vendor', 'Marketplace', 'Status', 'Progress', 'Started', 'Duration', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm">
#<span x-text="job.id"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="getVendorName(job.vendor_id)"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.marketplace"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
}"
x-text="job.status.replace('_', ' ').toUpperCase()">
</span>
</td>
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
</div>
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
<span x-text="job.error_count"></span> errors
</div>
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
Total: <span x-text="job.total_processed"></span>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="calculateDuration(job)"></span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<button
@click="viewJobDetails(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
<button
x-show="job.status === 'processing' || job.status === 'pending'"
@click="refreshJobStatus(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Refresh Status"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination(show_condition="!loading && pagination.total > 0") }}
</div>
</div>
{{ job_details_modal() }}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace.js') }}?v=2"></script>
{% endblock %}

View File

@@ -0,0 +1,210 @@
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-exceptions-tab.html #}
{# Exceptions tab for admin Letzshop management - Order Item Exception Resolution #}
{% from 'shared/macros/pagination.html' import pagination %}
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Product Exceptions</h3>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Resolve unmatched products from order imports' : 'All exceptions across vendors'"></p>
</div>
<button
@click="loadExceptions()"
:disabled="loadingExceptions"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
<span x-show="!loadingExceptions" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loadingExceptions" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Pending Exceptions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
<span x-html="$icon('exclamation-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.pending || 0"></p>
</div>
</div>
<!-- Resolved Exceptions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Resolved</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.resolved || 0"></p>
</div>
</div>
<!-- Ignored Exceptions -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:bg-gray-700">
<span x-html="$icon('ban', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Ignored</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.ignored || 0"></p>
</div>
</div>
<!-- Orders Affected -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:bg-purple-900">
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Orders Affected</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.orders_with_exceptions || 0"></p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap gap-4 items-center">
<!-- Search input -->
<div class="relative flex-1 min-w-[200px] max-w-md">
<span x-html="$icon('search', 'w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
<input
type="text"
x-model="exceptionsSearch"
@input.debounce.300ms="pagination.page = 1; loadExceptions()"
placeholder="Search by GTIN, product name, or order..."
class="w-full pl-9 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<button
x-show="exceptionsSearch"
@click="exceptionsSearch = ''; pagination.page = 1; loadExceptions()"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
<!-- Status filter -->
<select
x-model="exceptionsFilter"
@change="pagination.page = 1; loadExceptions()"
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="ignored">Ignored</option>
</select>
</div>
<!-- Exceptions Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product Info</th>
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">GTIN</th>
<th class="px-4 py-3">Order</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingExceptions && exceptions.length === 0">
<tr>
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading exceptions...</p>
</td>
</tr>
</template>
<template x-if="!loadingExceptions && exceptions.length === 0">
<tr>
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('check-circle', 'w-12 h-12 mx-auto mb-2 text-green-300')"></span>
<p class="font-medium">No exceptions found</p>
<p class="text-sm mt-1">All order items are properly matched to products</p>
</td>
</tr>
</template>
<template x-for="exc in exceptions" :key="exc.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="exc.original_product_name || 'Unknown Product'"></p>
<p class="text-xs text-gray-500" x-show="exc.original_sku" x-text="'SKU: ' + exc.original_sku"></p>
</div>
</div>
</td>
<!-- Vendor column (only in cross-vendor view) -->
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="exc.vendor_name || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm">
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="exc.original_gtin || 'No GTIN'"></code>
</td>
<td class="px-4 py-3 text-sm">
<a
:href="'/admin/orders/' + exc.order_id"
class="text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="exc.order_number"
></a>
<p class="text-xs text-gray-500" x-text="formatDate(exc.order_date)"></p>
</td>
<td class="px-4 py-3 text-xs">
<span
class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': exc.status === 'pending',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': exc.status === 'resolved',
'text-gray-700 bg-gray-100 dark:bg-gray-600 dark:text-gray-100': exc.status === 'ignored'
}"
x-text="exc.status.toUpperCase()"
></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="formatDate(exc.created_at)"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<template x-if="exc.status === 'pending'">
<button
@click="openResolveModal(exc)"
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
title="Resolve - Assign Product"
>
<span x-html="$icon('check', 'w-4 h-4 mr-1')"></span>
Resolve
</button>
</template>
<template x-if="exc.status === 'pending'">
<button
@click="ignoreException(exc)"
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
title="Ignore Exception"
>
<span x-html="$icon('ban', 'w-4 h-4')"></span>
</button>
</template>
<template x-if="exc.status === 'resolved'">
<span class="text-xs text-gray-500">
<span x-html="$icon('check', 'w-3 h-3 inline mr-1')"></span>
Resolved
</span>
</template>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ pagination(show_condition="!loadingExceptions && pagination.total > 0") }}
</div>

View File

@@ -0,0 +1,326 @@
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-jobs-table.html #}
{# Unified jobs table for admin Letzshop management - Import, Export, and Sync jobs #}
{% from 'shared/macros/pagination.html' import pagination %}
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Recent Jobs</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-show="selectedVendor">Product imports, exports, and order sync history</span>
<span x-show="!selectedVendor">All Letzshop jobs across all vendors</span>
</p>
</div>
<button
@click="loadJobs()"
:disabled="loadingJobs"
class="flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none disabled:opacity-50"
>
<span x-show="!loadingJobs" x-html="$icon('refresh', 'w-4 h-4 mr-1')"></span>
<span x-show="loadingJobs" x-html="$icon('spinner', 'w-4 h-4 mr-1')"></span>
Refresh
</button>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap gap-3">
<select
x-model="jobsFilter.type"
@change="loadJobs()"
class="px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Types</option>
<option value="import">Product Import</option>
<option value="export">Product Export</option>
<option value="historical_import">Historical Order Import</option>
<option value="order_sync">Order Sync</option>
</select>
<select
x-model="jobsFilter.status"
@change="loadJobs()"
class="px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="completed_with_errors">Completed with Errors</option>
</select>
</div>
<!-- Jobs Table -->
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-700">
<th class="px-4 py-3">ID</th>
<th class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Records</th>
<th class="px-4 py-3">Started</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingJobs && jobs.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading jobs...</p>
</td>
</tr>
</template>
<template x-if="!loadingJobs && jobs.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('collection', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No jobs found</p>
<p class="text-sm mt-1">Import products or sync orders to see job history</p>
</td>
</tr>
</template>
<template x-for="job in jobs" :key="job.id + '-' + job.type">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3 text-sm font-medium">
<span x-text="'#' + job.id"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.vendor_code || job.vendor_name || '-'"></span>
</td>
<td class="px-4 py-3">
<span
class="px-2 py-1 text-xs font-medium rounded-full"
:class="{
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300': job.type === 'import',
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': job.type === 'export',
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300': job.type === 'historical_import',
'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300': job.type === 'order_sync'
}"
>
<span x-show="job.type === 'import'" x-html="$icon('cloud-download', 'inline w-3 h-3 mr-1')"></span>
<span x-show="job.type === 'export'" x-html="$icon('cloud-upload', 'inline w-3 h-3 mr-1')"></span>
<span x-show="job.type === 'historical_import'" x-html="$icon('clock', 'inline w-3 h-3 mr-1')"></span>
<span x-show="job.type === 'order_sync'" x-html="$icon('refresh', 'inline w-3 h-3 mr-1')"></span>
<span x-text="job.type === 'import' ? 'Product Import' : job.type === 'export' ? 'Product Export' : job.type === 'historical_import' ? 'Historical Import' : 'Order Sync'"></span>
</span>
</td>
<td class="px-4 py-3">
<span
class="px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="{
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-300': job.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed' || job.status === 'success',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors' || job.status === 'partial'
}"
x-text="job.status.replace(/_/g, ' ').toUpperCase()"
></span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
<span class="text-green-600 dark:text-green-400" x-text="job.records_succeeded || 0"></span>
<span class="text-gray-400">/</span>
<span x-text="job.records_processed || 0"></span>
<span x-show="job.records_failed > 0" class="text-red-600 dark:text-red-400">
(<span x-text="job.records_failed"></span> failed)
</span>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="formatDate(job.started_at || job.created_at)"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="formatDuration(job.started_at, job.completed_at)"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<button
x-show="(job.type === 'import' || job.type === 'historical_import') && (job.status === 'failed' || job.status === 'completed_with_errors')"
@click="viewJobErrors(job)"
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
title="View Errors"
>
<span x-html="$icon('exclamation-circle', 'w-4 h-4')"></span>
</button>
<button
@click="viewJobDetails(job)"
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ pagination(show_condition="!loadingJobs && pagination.total > 0") }}
</div>
</div>
<!-- Job Details Modal -->
<div
x-show="showJobDetailsModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showJobDetailsModal = false"
x-cloak
>
<div
x-show="showJobDetailsModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-lg shadow-xl"
>
<!-- Header -->
<header class="flex justify-between items-center px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Job Details</h3>
<button
@click="showJobDetailsModal = false"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<span x-html="$icon('close', 'w-5 h-5')"></span>
</button>
</header>
<!-- Body -->
<div class="px-6 py-4 space-y-4">
<!-- Job Info Grid -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Job ID:</span>
<span class="ml-2 text-gray-900 dark:text-gray-100">#<span x-text="selectedJobDetails?.id"></span></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Type:</span>
<span class="ml-2">
<span
class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="{
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300': selectedJobDetails?.type === 'import',
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': selectedJobDetails?.type === 'export',
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300': selectedJobDetails?.type === 'historical_import',
'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300': selectedJobDetails?.type === 'order_sync'
}"
x-text="selectedJobDetails?.type === 'import' ? 'Product Import' : selectedJobDetails?.type === 'export' ? 'Product Export' : selectedJobDetails?.type === 'historical_import' ? 'Historical Import' : 'Order Sync'"
></span>
</span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Status:</span>
<span class="ml-2">
<span
class="px-2 py-0.5 text-xs font-semibold rounded-full"
:class="{
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-300': selectedJobDetails?.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': selectedJobDetails?.status === 'processing',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': selectedJobDetails?.status === 'completed' || selectedJobDetails?.status === 'success',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': selectedJobDetails?.status === 'failed',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': selectedJobDetails?.status === 'completed_with_errors' || selectedJobDetails?.status === 'partial'
}"
x-text="selectedJobDetails?.status?.replace(/_/g, ' ').toUpperCase()"
></span>
</span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Vendor:</span>
<span class="ml-2 text-gray-900 dark:text-gray-100" x-text="selectedJobDetails?.vendor_code || selectedJobDetails?.vendor_name || selectedVendor?.name || '-'"></span>
</div>
</div>
<!-- Records Info -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">Records</h4>
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="selectedJobDetails?.records_succeeded || 0"></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Succeeded</div>
</div>
<div>
<div class="text-2xl font-bold text-gray-600 dark:text-gray-300" x-text="selectedJobDetails?.records_processed || 0"></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Processed</div>
</div>
<div>
<div class="text-2xl font-bold text-red-600 dark:text-red-400" x-text="selectedJobDetails?.records_failed || 0"></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Failed</div>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="text-sm space-y-2">
<div class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400">Started:</span>
<span class="text-gray-900 dark:text-gray-100" x-text="formatDate(selectedJobDetails?.started_at || selectedJobDetails?.created_at)"></span>
</div>
<div class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400">Completed:</span>
<span class="text-gray-900 dark:text-gray-100" x-text="selectedJobDetails?.completed_at ? formatDate(selectedJobDetails?.completed_at) : 'In progress...'"></span>
</div>
<div class="flex justify-between">
<span class="font-medium text-gray-600 dark:text-gray-400">Duration:</span>
<span class="text-gray-900 dark:text-gray-100" x-text="formatDuration(selectedJobDetails?.started_at || selectedJobDetails?.created_at, selectedJobDetails?.completed_at)"></span>
</div>
</div>
<!-- Export Details (for export jobs) -->
<template x-if="selectedJobDetails?.type === 'export' && selectedJobDetails?.error_details">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h4 class="font-medium text-blue-700 dark:text-blue-300 mb-2">Export Details</h4>
<p class="text-sm text-blue-600 dark:text-blue-400 mb-2">
Products exported: <span class="font-medium" x-text="selectedJobDetails?.error_details?.products_exported || 0"></span>
</p>
<template x-if="selectedJobDetails?.error_details?.files">
<div class="space-y-1">
<template x-for="file in selectedJobDetails.error_details.files" :key="file.language">
<div class="text-xs flex justify-between items-center py-1 border-b border-blue-100 dark:border-blue-800 last:border-0">
<span class="font-medium text-blue-700 dark:text-blue-300" x-text="file.language?.toUpperCase()"></span>
<span x-show="file.error" class="text-red-600 dark:text-red-400" x-text="'Failed: ' + file.error"></span>
<span x-show="!file.error" class="text-blue-600 dark:text-blue-400">
<span x-text="file.filename"></span>
<span class="text-gray-400 ml-1">(<span x-text="(file.size_bytes / 1024).toFixed(1)"></span> KB)</span>
</span>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Error Details -->
<template x-if="selectedJobDetails?.error_message || (selectedJobDetails?.error_details?.error && selectedJobDetails?.type !== 'export')">
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
<h4 class="font-medium text-red-700 dark:text-red-300 mb-2">Error</h4>
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJobDetails?.error_message || selectedJobDetails?.error_details?.error"></p>
</div>
</template>
</div>
<!-- Footer -->
<footer class="px-6 py-4 border-t dark:border-gray-700 flex justify-end">
<button
@click="showJobDetailsModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none"
>
Close
</button>
</footer>
</div>
</div>

View File

@@ -0,0 +1,307 @@
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-orders-tab.html #}
{# Orders tab for admin Letzshop management #}
{% from 'shared/macros/pagination.html' import pagination %}
<!-- Header with Import Buttons -->
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Orders</h3>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Manage Letzshop orders for this vendor' : 'All Letzshop orders across vendors'"></p>
</div>
<!-- Import buttons only shown when vendor is selected -->
<div x-show="selectedVendor" class="flex gap-2">
<button
@click="importHistoricalOrders()"
:disabled="!letzshopStatus.is_configured || importingHistorical"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
title="Import all historical confirmed and declined orders"
>
<span x-show="!importingHistorical" x-html="$icon('archive', 'w-4 h-4 mr-2')"></span>
<span x-show="importingHistorical" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-show="!importingHistorical">Import History</span>
<span x-show="importingHistorical" x-text="historicalImportProgress?.message || 'Starting...'"></span>
</button>
<button
@click="importOrders()"
:disabled="!letzshopStatus.is_configured || importingOrders"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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 disabled:cursor-not-allowed"
>
<span x-show="!importingOrders" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="importingOrders" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importingOrders ? 'Importing...' : 'Import New'"></span>
</button>
</div>
</div>
<!-- Historical Import Progress -->
<div x-show="historicalImportProgress && importingHistorical" x-transition class="mb-6 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div class="flex items-center">
<span x-html="$icon('spinner', 'w-5 h-5 text-purple-500 mr-3')"></span>
<div class="flex-1">
<h4 class="font-medium text-purple-800 dark:text-purple-200">Historical Import in Progress</h4>
<p class="text-sm text-purple-700 dark:text-purple-300 mt-1" x-text="historicalImportProgress?.message"></p>
<div class="flex gap-4 mt-2 text-xs text-purple-600 dark:text-purple-400">
<span x-show="historicalImportProgress?.current_phase">
Phase: <strong x-text="historicalImportProgress?.current_phase"></strong>
</span>
<span x-show="historicalImportProgress?.shipments_fetched > 0">
Fetched: <strong x-text="historicalImportProgress?.shipments_fetched"></strong>
</span>
<span x-show="historicalImportProgress?.orders_processed > 0">
Processed: <strong x-text="historicalImportProgress?.orders_processed"></strong>
</span>
</div>
</div>
</div>
</div>
<!-- Historical Import Result -->
<div x-show="historicalImportResult" x-transition class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start justify-between">
<div class="flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 text-blue-500 mr-3 mt-0.5')"></span>
<div>
<h4 class="font-medium text-blue-800 dark:text-blue-200">Historical Import Complete</h4>
<div class="text-sm text-blue-700 dark:text-blue-300 mt-1">
<span x-text="historicalImportResult?.imported + ' imported'"></span> ·
<span x-text="historicalImportResult?.updated + ' updated'"></span> ·
<span x-text="historicalImportResult?.skipped + ' skipped'"></span>
</div>
<div x-show="historicalImportResult?.products_matched > 0 || historicalImportResult?.products_not_found > 0" class="text-sm text-blue-600 dark:text-blue-400 mt-1">
<span x-text="historicalImportResult?.products_matched + ' products matched by EAN'"></span> ·
<span x-text="historicalImportResult?.products_not_found + ' not found'"></span>
</div>
</div>
</div>
<button @click="historicalImportResult = null" class="text-blue-500 hover:text-blue-700">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8" :class="selectedVendor ? 'md:grid-cols-5' : 'md:grid-cols-4'">
<!-- Connection Status (only when vendor selected) -->
<div x-show="selectedVendor" class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div :class="letzshopStatus.is_configured ? 'bg-green-100 dark:bg-green-900' : 'bg-gray-100 dark:bg-gray-700'" class="p-3 mr-4 rounded-full">
<span x-html="$icon(letzshopStatus.is_configured ? 'check' : 'x', letzshopStatus.is_configured ? 'w-5 h-5 text-green-500' : 'w-5 h-5 text-gray-400')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Connection</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'"></p>
</div>
</div>
<!-- Pending Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.pending"></p>
</div>
</div>
<!-- Confirmed/Processing Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Confirmed</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.processing"></p>
</div>
</div>
<!-- Declined/Cancelled Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Declined</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.cancelled"></p>
</div>
</div>
<!-- Shipped Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:bg-blue-900">
<span x-html="$icon('truck', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Shipped</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.shipped"></p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap gap-4 items-center">
<!-- Search input -->
<div class="relative flex-1 min-w-[200px] max-w-md">
<span x-html="$icon('search', 'w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
<input
type="text"
x-model="ordersSearch"
@input.debounce.300ms="pagination.page = 1; loadOrders()"
placeholder="Search by order #, name, or email..."
class="w-full pl-9 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<button
x-show="ordersSearch"
@click="ordersSearch = ''; pagination.page = 1; loadOrders()"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
<!-- Status filter -->
<select
x-model="ordersFilter"
@change="ordersHasDeclinedItems = false; pagination.page = 1; loadOrders()"
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="processing">Confirmed</option>
<option value="cancelled">Declined</option>
<option value="shipped">Shipped</option>
</select>
<!-- Declined items filter -->
<button
@click="ordersFilter = ''; ordersHasDeclinedItems = !ordersHasDeclinedItems; pagination.page = 1; loadOrders()"
:class="ordersHasDeclinedItems ? 'bg-red-100 dark:bg-red-900 border-red-300 dark:border-red-700 text-red-700 dark:text-red-300' : 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:title="ordersHasDeclinedItems ? 'Showing orders with declined items' : 'Show only orders with declined items'"
>
<span x-html="$icon('x-circle', 'w-4 h-4 inline mr-1')"></span>
Has Declined Items
<span x-show="orderStats.has_declined_items > 0" class="ml-1 px-1.5 py-0.5 text-xs bg-red-200 dark:bg-red-800 rounded-full" x-text="orderStats.has_declined_items"></span>
</button>
</div>
<!-- Not Configured Warning (only when vendor selected) -->
<div x-show="selectedVendor && !letzshopStatus.is_configured" class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center">
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-500 mr-3')"></span>
<div>
<h4 class="font-medium text-yellow-800 dark:text-yellow-200">API Not Configured</h4>
<p class="text-sm text-yellow-700 dark:text-yellow-300">Configure the Letzshop API key in the Settings tab to import and manage orders.</p>
</div>
</div>
</div>
<!-- Orders Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Order</th>
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Total</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loadingOrders && orders.length === 0">
<tr>
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading orders...</p>
</td>
</tr>
</template>
<template x-if="!loadingOrders && orders.length === 0">
<tr>
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('inbox', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No orders found</p>
<p class="text-sm mt-1" x-text="selectedVendor ? 'Click Import Orders to fetch orders from Letzshop' : 'Select a vendor to import orders'"></p>
</td>
</tr>
</template>
<template x-for="order in orders" :key="order.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="order.external_order_number || order.order_number"></p>
<p class="text-xs text-gray-500" x-text="'#' + order.id"></p>
</div>
</div>
</td>
<!-- Vendor column (only in cross-vendor view) -->
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="order.vendor_name || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm">
<p x-text="order.customer_email || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="order.total_amount ? order.total_amount + ' ' + order.currency : 'N/A'"></span>
</td>
<td class="px-4 py-3 text-xs">
<span
class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': order.status === 'pending',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'processing',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': order.status === 'cancelled',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'shipped'
}"
x-text="order.status === 'cancelled' ? 'DECLINED' : (order.status === 'processing' ? 'CONFIRMED' : order.status.toUpperCase())"
></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="formatDate(order.order_date || order.created_at)"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
x-show="order.status === 'pending'"
@click="confirmOrder(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
title="Confirm Order"
>
<span x-html="$icon('check', 'w-4 h-4')"></span>
</button>
<button
x-show="order.status === 'pending'"
@click="declineOrder(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
title="Decline Order"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
<button
x-show="order.status === 'processing'"
@click="openTrackingModal(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-blue-600 transition-colors duration-150 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900"
title="Set Tracking"
>
<span x-html="$icon('truck', 'w-4 h-4')"></span>
</button>
<button
@click="viewOrderDetails(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ pagination(show_condition="!loadingOrders && pagination.total > 0") }}
</div>

View File

@@ -0,0 +1,362 @@
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-products-tab.html #}
{# Products tab for admin Letzshop management - Product listing with Import/Export #}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/tables.html' import table_wrapper %}
<!-- Header with Import/Export Buttons -->
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Letzshop Products</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-show="selectedVendor" x-text="'Products from ' + (selectedVendor?.name || '')"></span>
<span x-show="!selectedVendor">All Letzshop marketplace products</span>
</p>
</div>
<div class="flex items-center gap-3" x-show="selectedVendor">
<!-- Import Button (only when vendor selected) -->
<button
@click="showImportModal = true"
:disabled="importing"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-show="!importing" x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Importing...' : 'Import'"></span>
</button>
<!-- Export Button (only when vendor selected) -->
<button
@click="exportAllLanguages()"
:disabled="exporting"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
<span x-show="!exporting" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="exporting ? 'Exporting...' : 'Export'"></span>
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 mb-6 md:grid-cols-2 xl:grid-cols-4">
<!-- Total Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Products</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.total || 0"></p>
</div>
</div>
<!-- Active Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.active || 0"></p>
</div>
</div>
<!-- Inactive Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Inactive</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.inactive || 0"></p>
</div>
</div>
<!-- Last Sync -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('refresh', 'w-5 h-5')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Sync</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.last_sync || 'Never'"></p>
</div>
</div>
</div>
<!-- Search and Filters Bar -->
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-xl">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="productFilters.search"
@input.debounce.300ms="loadProducts()"
placeholder="Search by title, GTIN, or SKU..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Status Filter -->
<select
x-model="productFilters.is_active"
@change="pagination.page = 1; loadProducts()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Refresh Button -->
<button
@click="loadProducts()"
:disabled="loadingProducts"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Refresh products"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Loading State -->
<div x-show="loadingProducts" class="flex items-center justify-center py-12">
<span x-html="$icon('spinner', 'w-8 h-8 text-purple-600')"></span>
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading products...</span>
</div>
<!-- Products Table -->
<div x-show="!loadingProducts">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3" x-show="!selectedVendor">Vendor</th>
<th class="px-4 py-3">Identifiers</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="products.length === 0">
<tr>
<td :colspan="selectedVendor ? 5 : 6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('cube', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No products found</p>
<p class="text-xs mt-1" x-text="productFilters.search ? 'Try adjusting your search' : (selectedVendor ? 'Import products to get started' : 'No Letzshop products in the catalog')"></p>
</div>
</td>
</tr>
</template>
<!-- Product Rows -->
<template x-for="product in products" :key="product.id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Product Info -->
<td class="px-4 py-3">
<div class="flex items-center">
<!-- Product Image -->
<div class="w-10 h-10 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
<template x-if="product.image_link">
<img :src="product.image_link" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
</template>
<template x-if="!product.image_link">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Product Details -->
<div class="min-w-0">
<a :href="'/admin/letzshop/products/' + product.id" class="font-semibold text-sm truncate max-w-xs hover:text-purple-600 dark:hover:text-purple-400" x-text="product.title || 'Untitled'"></a>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
</div>
</div>
</td>
<!-- Vendor (shown when no vendor filter) -->
<td class="px-4 py-3 text-sm" x-show="!selectedVendor">
<span class="font-medium" x-text="product.vendor_name || '-'"></span>
</td>
<!-- Identifiers -->
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<template x-if="product.gtin">
<p class="text-xs"><span class="text-gray-500">GTIN:</span> <span x-text="product.gtin" class="font-mono"></span></p>
</template>
<template x-if="product.sku">
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.sku" class="font-mono"></span></p>
</template>
<template x-if="!product.gtin && !product.sku">
<p class="text-xs text-gray-400">No identifiers</p>
</template>
</div>
</td>
<!-- Price -->
<td class="px-4 py-3 text-sm">
<template x-if="product.price_numeric">
<p class="font-medium" x-text="formatPrice(product.price_numeric, product.currency || 'EUR')"></p>
</template>
<template x-if="!product.price_numeric">
<p class="text-gray-400">-</p>
</template>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="product.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="product.is_active ? 'Active' : 'Inactive'">
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<a
:href="'/admin/letzshop/products/' + product.id"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination(show_condition="!loadingProducts && pagination.total > 0") }}
</div>
<!-- Import Modal -->
<div
x-show="showImportModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showImportModal = false"
x-cloak
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-lg"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Import Products from Letzshop</h3>
<button @click="showImportModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Import products from Letzshop CSV feeds. All languages will be imported.
</p>
<!-- Quick Fill Buttons -->
<div class="mb-4" x-show="selectedVendor?.letzshop_csv_url_fr || selectedVendor?.letzshop_csv_url_en || selectedVendor?.letzshop_csv_url_de">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Quick Import
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="startImportAllLanguages()"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
Import All Languages
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Imports products from all configured CSV URLs (FR, EN, DE)
</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 mt-4">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Or import from custom URL:</p>
<form @submit.prevent="startImportFromUrl()">
<!-- CSV URL -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
CSV URL
</label>
<input
x-model="importForm.csv_url"
type="url"
placeholder="https://letzshop.lu/feeds/products.csv"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
<!-- Language Selection -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Language for this URL
</label>
<div class="flex gap-2">
<button type="button" @click="importForm.language = 'fr'"
:class="importForm.language === 'fr' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
<span class="fi fi-fr"></span> FR
</button>
<button type="button" @click="importForm.language = 'de'"
:class="importForm.language === 'de' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
<span class="fi fi-de"></span> DE
</button>
<button type="button" @click="importForm.language = 'en'"
:class="importForm.language === 'en' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
<span class="fi fi-gb"></span> EN
</button>
</div>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
@click="showImportModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
:disabled="importing || !importForm.csv_url"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Import
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,410 @@
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-settings-tab.html #}
{# Settings tab for admin Letzshop management - API credentials, CSV URLs, Import/Export settings #}
{% from 'shared/macros/inputs.html' import number_stepper %}
<div class="grid gap-6 lg:grid-cols-2">
<!-- API Configuration Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Letzshop API Configuration
</h3>
<form @submit.prevent="saveCredentials()">
<!-- API Key -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
API Key <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
:type="showApiKey ? 'text' : 'password'"
x-model="settingsForm.api_key"
:placeholder="credentials ? credentials.api_key_masked : 'Enter Letzshop API key'"
class="block w-full px-3 py-2 pr-10 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
/>
<button
type="button"
@click="showApiKey = !showApiKey"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
<span x-html="$icon(showApiKey ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Get your API key from the Letzshop merchant portal
</p>
</div>
<!-- Test Mode -->
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="settingsForm.test_mode_enabled"
class="form-checkbox h-5 w-5 text-orange-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-orange-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Test Mode</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
When enabled, operations (confirm, reject, tracking) will NOT be sent to Letzshop API
</p>
</div>
<!-- Test Mode Warning -->
<div x-show="settingsForm.test_mode_enabled" class="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<div class="flex items-center">
<span x-html="$icon('exclamation', 'w-5 h-5 text-orange-500 mr-2')"></span>
<span class="text-sm text-orange-700 dark:text-orange-300 font-medium">Test Mode Active</span>
</div>
<p class="mt-1 text-xs text-orange-600 dark:text-orange-400 ml-7">
All Letzshop API mutations are disabled. Orders can be imported but confirmations/rejections will only be saved locally.
</p>
</div>
<!-- Auto Sync -->
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="settingsForm.auto_sync_enabled"
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Enable Auto-Sync</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
Automatically import new orders periodically
</p>
</div>
<!-- Sync Interval -->
<div class="mb-6" x-show="settingsForm.auto_sync_enabled">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Sync Interval
</label>
<select
x-model="settingsForm.sync_interval_minutes"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="15">Every 15 minutes</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every hour</option>
<option value="120">Every 2 hours</option>
<option value="360">Every 6 hours</option>
</select>
</div>
<!-- Last Sync Info -->
<div x-show="credentials" class="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Sync</h4>
<div class="grid gap-2 text-sm text-gray-600 dark:text-gray-400">
<p>
<span class="font-medium">Status:</span>
<span
class="ml-2 px-2 py-0.5 text-xs rounded-full"
:class="{
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300': credentials?.last_sync_status === 'success',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300': credentials?.last_sync_status === 'partial',
'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300': credentials?.last_sync_status === 'failed',
'bg-gray-100 text-gray-700 dark:bg-gray-600 dark:text-gray-300': !credentials?.last_sync_status
}"
x-text="credentials?.last_sync_status || 'Never'"
></span>
</p>
<p x-show="credentials?.last_sync_at">
<span class="font-medium">Time:</span>
<span class="ml-2" x-text="formatDate(credentials?.last_sync_at)"></span>
</p>
<p x-show="credentials?.last_sync_error" class="text-red-600 dark:text-red-400">
<span class="font-medium">Error:</span>
<span class="ml-2" x-text="credentials?.last_sync_error"></span>
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3">
<button
type="submit"
:disabled="savingCredentials"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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="!savingCredentials" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
<span x-show="savingCredentials" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="savingCredentials ? 'Saving...' : 'Save Credentials'"></span>
</button>
<button
type="button"
@click="testConnection()"
:disabled="testingConnection || !letzshopStatus.is_configured"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
>
<span x-show="!testingConnection" x-html="$icon('lightning-bolt', 'w-4 h-4 mr-2')"></span>
<span x-show="testingConnection" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="testingConnection ? 'Testing...' : 'Test Connection'"></span>
</button>
<button
type="button"
x-show="credentials"
@click="deleteCredentials()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-red-600 transition-colors duration-150 bg-white dark:bg-gray-800 border border-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none"
>
<span x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
Remove
</button>
</div>
</form>
</div>
</div>
<!-- CSV URLs Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Letzshop CSV URLs
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure the CSV feed URLs for product imports. These URLs are used for quick-fill in the Products tab.
</p>
<form @submit.prevent="saveCsvUrls()">
<!-- French CSV URL -->
<div class="mb-4">
<label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="fi fi-fr mr-2"></span>
French CSV URL
</label>
<input
x-model="settingsForm.letzshop_csv_url_fr"
type="url"
placeholder="https://letzshop.lu/feeds/products_fr.csv"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
/>
</div>
<!-- English CSV URL -->
<div class="mb-4">
<label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="fi fi-gb mr-2"></span>
English CSV URL
</label>
<input
x-model="settingsForm.letzshop_csv_url_en"
type="url"
placeholder="https://letzshop.lu/feeds/products_en.csv"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
/>
</div>
<!-- German CSV URL -->
<div class="mb-6">
<label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="fi fi-de mr-2"></span>
German CSV URL
</label>
<input
x-model="settingsForm.letzshop_csv_url_de"
type="url"
placeholder="https://letzshop.lu/feeds/products_de.csv"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
/>
</div>
<!-- Save Button -->
<button
type="submit"
:disabled="savingCsvUrls"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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="!savingCsvUrls" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
<span x-show="savingCsvUrls" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="savingCsvUrls ? 'Saving...' : 'Save CSV URLs'"></span>
</button>
</form>
<!-- Info Box -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="flex">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-500 mr-2 flex-shrink-0')"></span>
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium">About CSV URLs</p>
<p class="mt-1">These URLs should point to the vendor's product feed on Letzshop. The feed is typically provided by Letzshop as part of the merchant integration.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Import/Export Settings Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Import / Export Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure settings for product import and export operations.
</p>
<!-- Import Settings -->
<div class="mb-6">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2 text-purple-500')"></span>
Import Settings
</h4>
<div class="pl-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Batch Size
</label>
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Products processed per batch (100-5000). Higher = faster but more memory.
</p>
</div>
</div>
<!-- Export Settings -->
<div class="border-t border-gray-200 dark:border-gray-600 pt-6">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
<span x-html="$icon('upload', 'w-4 h-4 mr-2 text-green-500')"></span>
Export Settings
</h4>
<div class="pl-6">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="exportIncludeInactive"
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
Export products that are currently marked as inactive
</p>
</div>
</div>
<!-- Export Info Box -->
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Export Behavior</h4>
<ul class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
Exports all languages (FR, DE, EN) automatically
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
CSV files are placed in a folder for Letzshop pickup
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
Letzshop scheduler fetches files periodically
</li>
</ul>
</div>
</div>
</div>
<!-- Carrier Settings Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Carrier Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Configure default carrier and label URL prefixes for shipping labels.
</p>
<form @submit.prevent="saveCarrierSettings()">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Default Carrier -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Default Carrier
</label>
<select
x-model="settingsForm.default_carrier"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">-- Select carrier --</option>
<option value="greco">Greco</option>
<option value="colissimo">Colissimo</option>
<option value="xpresslogistics">XpressLogistics</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Letzshop automatically assigns carriers based on shipment data
</p>
</div>
<!-- Placeholder for alignment -->
<div></div>
<!-- Greco Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-blue-500 mr-2"></span>
Greco Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_greco_label_url"
type="url"
placeholder="https://dispatchweb.fr/Tracky/Home/"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Label URL = Prefix + Shipment Number (e.g., H74683403433)
</p>
</div>
<!-- Colissimo Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2"></span>
Colissimo Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_colissimo_label_url"
type="url"
placeholder="https://..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
<!-- XpressLogistics Label URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
<span class="inline-flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
XpressLogistics Label URL Prefix
</span>
</label>
<input
x-model="settingsForm.carrier_xpresslogistics_label_url"
type="url"
placeholder="https://..."
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
</div>
<!-- Save Button -->
<div class="mt-6">
<button
type="submit"
:disabled="savingCarrierSettings"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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="!savingCarrierSettings" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
<span x-show="savingCarrierSettings" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="savingCarrierSettings ? 'Saving...' : 'Save Carrier Settings'"></span>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,171 @@
{# app/modules/marketplace/templates/marketplace/public/find-shop.html #}
{# Letzshop Vendor Finder Page #}
{% extends "public/base.html" %}
{% block title %}{{ _("platform.find_shop.title") }} - Wizamart{% endblock %}
{% block content %}
<div x-data="vendorFinderData()" class="py-16 lg:py-24">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{# Header #}
<div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.find_shop.title") }}
</h1>
<p class="text-xl text-gray-600 dark:text-gray-400">
{{ _("platform.find_shop.subtitle") }}
</p>
</div>
{# Search Form #}
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 shadow-lg">
<div class="flex flex-col sm:flex-row gap-4">
<input
type="text"
x-model="searchQuery"
@keyup.enter="lookupVendor()"
placeholder="{{ _('platform.find_shop.search_placeholder') }}"
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<button
@click="lookupVendor()"
:disabled="loading || !searchQuery.trim()"
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center min-w-[140px]">
<template x-if="loading">
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<template x-if="!loading">
<span>{{ _("platform.find_shop.search_button") }}</span>
</template>
</button>
</div>
{# Examples #}
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
<strong>{{ _("platform.find_shop.examples") }}</strong>
<ul class="list-disc list-inside mt-1">
<li>https://letzshop.lu/vendors/my-shop</li>
<li>letzshop.lu/vendors/my-shop</li>
<li>my-shop</li>
</ul>
</div>
</div>
{# Results #}
<template x-if="result">
<div class="mt-8 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<template x-if="result.found">
<div class="p-8">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-green-600 font-medium mb-1">{{ _("platform.find_shop.found") }}</p>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white" x-text="result.vendor.name"></h2>
<a :href="result.vendor.letzshop_url" target="_blank"
class="text-indigo-600 dark:text-indigo-400 hover:underline mt-1 inline-block"
x-text="result.vendor.letzshop_url"></a>
<template x-if="result.vendor.description">
<p class="text-gray-600 dark:text-gray-400 mt-4" x-text="result.vendor.description"></p>
</template>
</div>
<template x-if="result.vendor.logo_url">
<img :src="result.vendor.logo_url" :alt="result.vendor.name"
class="w-20 h-20 rounded-xl object-cover border border-gray-200 dark:border-gray-700"/>
</template>
</div>
<div class="mt-8 flex items-center gap-4">
<template x-if="!result.vendor.is_claimed">
<a :href="'/signup?letzshop=' + result.vendor.slug"
class="px-8 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl transition-colors">
{{ _("platform.find_shop.claim_button") }}
</a>
</template>
<template x-if="result.vendor.is_claimed">
<div class="px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-xl">
<span class="inline-flex items-center">
<span class="text-yellow-500 mr-2">{{ _("platform.find_shop.claimed_badge") }}</span>
</span>
{{ _("platform.find_shop.already_claimed") }}
</div>
</template>
</div>
</div>
</template>
<template x-if="!result.found">
<div class="p-8 text-center">
<svg class="w-16 h-16 text-gray-400 mx-auto mb-4" 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-xl font-semibold text-gray-900 dark:text-white mb-2">{{ _("platform.find_shop.not_found") }}</h3>
<p class="text-gray-600 dark:text-gray-400" x-text="result.error || '{{ _("platform.find_shop.not_found") }}'"></p>
<div class="mt-6">
<a href="/signup" class="text-indigo-600 dark:text-indigo-400 hover:underline">
{{ _("platform.find_shop.or_signup") }} &rarr;
</a>
</div>
</div>
</template>
</div>
</template>
{# Help Section #}
<div class="mt-12 text-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("platform.find_shop.need_help") }}</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
{{ _("platform.find_shop.no_account_yet") }}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="https://letzshop.lu" target="_blank"
class="px-6 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
{{ _("platform.find_shop.create_letzshop") }}
</a>
<a href="/signup"
class="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl transition-colors">
{{ _("platform.find_shop.signup_without") }}
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function vendorFinderData() {
return {
searchQuery: '',
result: null,
loading: false,
async lookupVendor() {
if (!this.searchQuery.trim()) return;
this.loading = true;
this.result = null;
try {
const response = await fetch('/api/v1/public/letzshop-vendors/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.searchQuery })
});
this.result = await response.json();
} catch (error) {
console.error('Lookup error:', error);
this.result = { found: false, error: 'Failed to lookup. Please try again.' };
} finally {
this.loading = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,686 @@
{# app/templates/vendor/letzshop.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/modals.html' import form_modal %}
{% block title %}Letzshop Orders{% endblock %}
{% block alpine_data %}vendorLetzshop(){% endblock %}
{% block extra_scripts %}
<script src="/static/modules/marketplace/vendor/js/letzshop.js"></script>
{% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Letzshop Orders', subtitle='Manage orders from Letzshop marketplace') %}
<button
@click="importOrders()"
:disabled="!status.is_configured || importing"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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 disabled:cursor-not-allowed"
>
<span x-show="!importing" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Importing...' : 'Import Orders'"></span>
</button>
<button
@click="refreshData()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
{% endcall %}
<!-- Success Message -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold" x-text="successMessage"></p>
</div>
<button @click="successMessage = ''" class="ml-auto text-green-700 dark:text-green-300 hover:text-green-900">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
{# noqa: FE-003 - Custom dismissible error with dark mode support not available in error_state macro #}
<!-- Error Message -->
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error</p>
<p class="text-sm" x-text="error"></p>
</div>
<button @click="error = ''" class="ml-auto text-red-700 dark:text-red-300 hover:text-red-900">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
<!-- Tabs -->
<div class="mb-6">
<div class="flex border-b border-gray-200 dark:border-gray-700">
<button
@click="activeTab = 'orders'"
:class="activeTab === 'orders' ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
>
<span class="flex items-center">
<span x-html="$icon('shopping-cart', 'w-4 h-4 mr-2')"></span>
Orders
<span x-show="orders.length > 0" class="ml-2 px-2 py-0.5 text-xs bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 rounded-full" x-text="totalOrders"></span>
</span>
</button>
<button
@click="activeTab = 'export'"
:class="activeTab === 'export' ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
>
<span class="flex items-center">
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Export
</span>
</button>
<button
@click="activeTab = 'settings'"
:class="activeTab === 'settings' ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
>
<span class="flex items-center">
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
Settings
</span>
</button>
</div>
</div>
<!-- Orders Tab -->
<div x-show="activeTab === 'orders'" x-transition>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Connection Status -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div :class="status.is_configured ? 'bg-green-100 dark:bg-green-900' : 'bg-gray-100 dark:bg-gray-700'" class="p-3 mr-4 rounded-full">
<span x-html="$icon(status.is_configured ? 'check' : 'x', status.is_configured ? 'w-5 h-5 text-green-500' : 'w-5 h-5 text-gray-400')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Connection</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="status.is_configured ? 'Configured' : 'Not Configured'"></p>
</div>
</div>
<!-- Pending Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.pending"></p>
</div>
</div>
<!-- Confirmed Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Confirmed</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.confirmed"></p>
</div>
</div>
<!-- Shipped Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:bg-blue-900">
<span x-html="$icon('truck', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Shipped</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.shipped"></p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap gap-4">
<select
x-model="filters.sync_status"
@change="loadOrders()"
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="rejected">Rejected</option>
<option value="shipped">Shipped</option>
</select>
</div>
<!-- Orders Table -->
{% call table_wrapper() %}
{{ table_header(['Order', 'Customer', 'Total', 'Status', 'Date', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading && orders.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading orders...</p>
</td>
</tr>
</template>
<template x-if="!loading && orders.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('inbox', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No orders found</p>
<p class="text-sm mt-1" x-show="status.is_configured">Click "Import Orders" to fetch orders from Letzshop</p>
<p class="text-sm mt-1" x-show="!status.is_configured">Configure your API key in Settings to get started</p>
</td>
</tr>
</template>
<template x-for="order in orders" :key="order.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="order.letzshop_order_number || order.letzshop_order_id"></p>
<p class="text-xs text-gray-500" x-text="'#' + order.id"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<p x-text="order.customer_email || 'N/A'"></p>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="order.total_amount ? order.total_amount + ' ' + order.currency : 'N/A'"></span>
</td>
<td class="px-4 py-3 text-xs">
<span
class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': order.sync_status === 'pending',
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.sync_status === 'confirmed',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': order.sync_status === 'rejected',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.sync_status === 'shipped'
}"
x-text="order.sync_status.toUpperCase()"
></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="formatDate(order.created_at)"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
x-show="order.sync_status === 'pending'"
@click="confirmOrder(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
title="Confirm Order"
>
<span x-html="$icon('check', 'w-4 h-4')"></span>
</button>
<button
x-show="order.sync_status === 'pending'"
@click="rejectOrder(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
title="Reject Order"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
<button
x-show="order.sync_status === 'confirmed'"
@click="openTrackingModal(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-blue-600 transition-colors duration-150 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900"
title="Set Tracking"
>
<span x-html="$icon('truck', 'w-4 h-4')"></span>
</button>
<button
@click="viewOrderDetails(order)"
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{# noqa: FE-001 - Uses flat variables (page, limit, totalOrders) instead of pagination object expected by macro #}
<!-- Pagination -->
<div x-show="totalOrders > limit" class="grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing <span x-text="((page - 1) * limit) + 1"></span>-<span x-text="Math.min(page * limit, totalOrders)"></span> of <span x-text="totalOrders"></span>
</span>
<span class="col-span-2"></span>
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
<nav aria-label="Table navigation">
<ul class="inline-flex items-center">
<li>
<button
@click="page--; loadOrders()"
:disabled="page <= 1"
class="px-3 py-1 rounded-md rounded-l-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<li>
<button
@click="page++; loadOrders()"
:disabled="page * limit >= totalOrders"
class="px-3 py-1 rounded-md rounded-r-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
>
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
</button>
</li>
</ul>
</nav>
</span>
</div>
</div>
<!-- Export Tab -->
<div x-show="activeTab === 'export'" x-transition>
<div class="grid gap-6 md:grid-cols-2">
<!-- Export Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Export Products to Letzshop
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Generate a Letzshop-compatible CSV file from your product catalog.
The file uses Google Shopping feed format and includes all required fields.
</p>
<!-- Language Selection -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Export Language
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select the language for product titles and descriptions
</p>
<div class="flex flex-wrap gap-3">
<button
@click="exportLanguage = 'fr'"
:class="exportLanguage === 'fr'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-fr"></span>
Francais
</button>
<button
@click="exportLanguage = 'de'"
:class="exportLanguage === 'de'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-de"></span>
Deutsch
</button>
<button
@click="exportLanguage = 'en'"
:class="exportLanguage === 'en'
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-500 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<span class="fi fi-gb"></span>
English
</button>
</div>
</div>
<!-- Include Inactive -->
<div class="mb-6">
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="exportIncludeInactive"
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
Export products that are currently marked as inactive
</p>
</div>
<!-- Download Button -->
<div class="flex flex-wrap gap-3">
<button
@click="downloadExport()"
:disabled="exporting"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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="!exporting" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="exporting ? 'Generating...' : 'Download CSV'"></span>
</button>
</div>
</div>
</div>
<!-- Export Info Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
CSV Format Information
</h3>
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">File Format</h4>
<ul class="list-disc list-inside space-y-1">
<li>Tab-separated values (TSV)</li>
<li>UTF-8 encoding</li>
<li>Google Shopping compatible</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">Included Fields</h4>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">id</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">title</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">description</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">price</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">image_link</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">availability</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">brand</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">gtin</span>
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded">+30 more</span>
</div>
</div>
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">How to Upload</h4>
<ol class="list-decimal list-inside space-y-1">
<li>Download the CSV file</li>
<li>Log in to your Letzshop merchant portal</li>
<li>Navigate to Products > Import</li>
<li>Upload the CSV file</li>
</ol>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="flex">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-500 mr-2 flex-shrink-0')"></span>
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium">Translation Fallback</p>
<p class="mt-1">If a product doesn't have a translation in the selected language, the system will use English, then fall back to any available translation.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div x-show="activeTab === 'settings'" x-transition>
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Letzshop API Configuration
</h3>
<form @submit.prevent="saveCredentials()">
<div class="grid gap-6 mb-6 md:grid-cols-2">
<!-- API Key -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
API Key <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
:type="showApiKey ? 'text' : 'password'"
x-model="credentialsForm.api_key"
:placeholder="credentials ? credentials.api_key_masked : 'Enter your Letzshop API key'"
class="block w-full px-3 py-2 pr-10 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
/>
<button
type="button"
@click="showApiKey = !showApiKey"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
<span x-html="$icon(showApiKey ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Get your API key from the Letzshop merchant portal
</p>
</div>
<!-- Auto Sync -->
<div>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
x-model="credentialsForm.auto_sync_enabled"
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
/>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Enable Auto-Sync</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
Automatically import new orders periodically
</p>
</div>
<!-- Sync Interval -->
<div x-show="credentialsForm.auto_sync_enabled">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Sync Interval (minutes)
</label>
<select
x-model="credentialsForm.sync_interval_minutes"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="15">Every 15 minutes</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every hour</option>
<option value="120">Every 2 hours</option>
<option value="360">Every 6 hours</option>
</select>
</div>
</div>
<!-- Last Sync Info -->
<div x-show="credentials" class="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Sync</h4>
<div class="grid gap-2 text-sm text-gray-600 dark:text-gray-400">
<p>
<span class="font-medium">Status:</span>
<span
class="ml-2 px-2 py-0.5 text-xs rounded-full"
:class="{
'bg-green-100 text-green-700': credentials?.last_sync_status === 'success',
'bg-yellow-100 text-yellow-700': credentials?.last_sync_status === 'partial',
'bg-red-100 text-red-700': credentials?.last_sync_status === 'failed',
'bg-gray-100 text-gray-700': !credentials?.last_sync_status
}"
x-text="credentials?.last_sync_status || 'Never'"
></span>
</p>
<p x-show="credentials?.last_sync_at">
<span class="font-medium">Time:</span>
<span x-text="formatDate(credentials?.last_sync_at)"></span>
</p>
<p x-show="credentials?.last_sync_error" class="text-red-600">
<span class="font-medium">Error:</span>
<span x-text="credentials?.last_sync_error"></span>
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3">
<button
type="submit"
:disabled="saving"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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="!saving" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? 'Saving...' : 'Save Credentials'"></span>
</button>
<button
type="button"
@click="testConnection()"
:disabled="testing || !status.is_configured"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
>
<span x-show="!testing" x-html="$icon('lightning-bolt', 'w-4 h-4 mr-2')"></span>
<span x-show="testing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="testing ? 'Testing...' : 'Test Connection'"></span>
</button>
<button
type="button"
x-show="credentials"
@click="deleteCredentials()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-red-600 transition-colors duration-150 bg-white dark:bg-gray-800 border border-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none"
>
<span x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
Remove
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Tracking Modal -->
{% call form_modal('trackingModal', 'Set Tracking Information', show_var='showTrackingModal', submit_action='submitTracking()', submit_text='Save Tracking', loading_var='submittingTracking', loading_text='Saving...', size='sm') %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Tracking Number <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="trackingForm.tracking_number"
required
placeholder="1Z999AA10123456784"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Carrier <span class="text-red-500">*</span>
</label>
<select
x-model="trackingForm.tracking_carrier"
required
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
>
<option value="">Select carrier...</option>
<option value="dhl">DHL</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="post_lu">Post Luxembourg</option>
<option value="dpd">DPD</option>
<option value="gls">GLS</option>
<option value="other">Other</option>
</select>
</div>
{% endcall %}
<!-- Order Details Modal -->
<div
x-show="showOrderModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showOrderModal = false"
>
<div
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl max-h-[80vh] overflow-y-auto"
@click.stop
>
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Order Details</h3>
<button @click="showOrderModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div x-show="selectedOrder" class="space-y-4">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Order Number:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.letzshop_order_number"></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Status:</span>
<span
class="ml-2 px-2 py-0.5 text-xs rounded-full"
:class="{
'bg-orange-100 text-orange-700': selectedOrder?.sync_status === 'pending',
'bg-green-100 text-green-700': selectedOrder?.sync_status === 'confirmed',
'bg-red-100 text-red-700': selectedOrder?.sync_status === 'rejected',
'bg-blue-100 text-blue-700': selectedOrder?.sync_status === 'shipped'
}"
x-text="selectedOrder?.sync_status?.toUpperCase()"
></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Customer:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.customer_email"></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Total:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.total_amount + ' ' + selectedOrder?.currency"></span>
</div>
<div x-show="selectedOrder?.tracking_number">
<span class="font-medium text-gray-600 dark:text-gray-400">Tracking:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.tracking_number + ' (' + selectedOrder?.tracking_carrier + ')'"></span>
</div>
<div>
<span class="font-medium text-gray-600 dark:text-gray-400">Created:</span>
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="formatDate(selectedOrder?.created_at)"></span>
</div>
</div>
<div x-show="selectedOrder?.inventory_units?.length > 0">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">Items</h4>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<template x-for="unit in selectedOrder?.inventory_units || []" :key="unit.id">
<div class="flex justify-between text-sm py-1 border-b border-gray-200 dark:border-gray-600 last:border-0">
<span class="text-gray-600 dark:text-gray-400" x-text="unit.id"></span>
<span
class="px-2 py-0.5 text-xs rounded-full"
:class="unit.state === 'confirmed' ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'"
x-text="unit.state"
></span>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,382 @@
{# app/templates/vendor/marketplace.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/inputs.html' import number_stepper %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, simple_pagination %}
{% from 'shared/macros/modals.html' import job_details_modal %}
{% block title %}Marketplace Import{% endblock %}
{% block alpine_data %}vendorMarketplace(){% endblock %}
{% block extra_scripts %}
<script src="/static/modules/marketplace/vendor/js/marketplace.js"></script>
{% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Marketplace Import', subtitle='Import products from Letzshop marketplace CSV feeds') %}
{{ refresh_button(loading_var='loading', onclick='refreshJobs()') }}
{% endcall %}
<!-- Success Message -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold" x-text="successMessage"></p>
</div>
</div>
<!-- Error Message -->
{{ error_state(title='Error', error_var='error', show_condition='error') }}
<!-- Import Form Card -->
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Start New Import
</h3>
<form @submit.prevent="startImport()">
<div class="grid gap-6 mb-4 md:grid-cols-2">
<!-- CSV URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
CSV URL <span class="text-red-500">*</span>
</label>
<input
x-model="importForm.csv_url"
type="url"
required
placeholder="https://example.com/products.csv"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter the URL of the Letzshop CSV feed
</p>
</div>
<!-- Language Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Language
</label>
<select
x-model="importForm.language"
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
>
<option value="fr">French (FR)</option>
<option value="en">English (EN)</option>
<option value="de">German (DE)</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Select the language of the CSV feed
</p>
</div>
<!-- Marketplace -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Marketplace
</label>
<input
x-model="importForm.marketplace"
type="text"
readonly
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-md cursor-not-allowed"
/>
</div>
<!-- Batch Size -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Batch Size
</label>
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Number of products to process per batch (100-5000)
</p>
</div>
</div>
<!-- Quick Fill Buttons -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Quick Fill
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="quickFill('fr')"
x-show="vendorSettings.letzshop_csv_url_fr"
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
>
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
French CSV
</button>
<button
type="button"
@click="quickFill('en')"
x-show="vendorSettings.letzshop_csv_url_en"
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
>
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
English CSV
</button>
<button
type="button"
@click="quickFill('de')"
x-show="vendorSettings.letzshop_csv_url_de"
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
>
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
German CSV
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-show="!vendorSettings.letzshop_csv_url_fr && !vendorSettings.letzshop_csv_url_en && !vendorSettings.letzshop_csv_url_de">
Configure Letzshop CSV URLs in settings to use quick fill
</p>
</div>
<!-- Submit Button -->
<div class="flex items-center justify-end">
<button
type="submit"
:disabled="importing || !importForm.csv_url"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 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 disabled:cursor-not-allowed"
>
<span x-show="!importing" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Import Jobs List -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Import History
</h3>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
</div>
<!-- Empty State -->
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
<p class="text-gray-600 dark:text-gray-400">No import jobs yet</p>
<p class="text-sm text-gray-500 dark:text-gray-500">Start your first import using the form above</p>
</div>
<!-- Jobs Table -->
<div x-show="!loading && jobs.length > 0">
{% call table_wrapper() %}
{{ table_header(['Job ID', 'Marketplace', 'Status', 'Progress', 'Started', 'Duration', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm">
#<span x-text="job.id"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.marketplace"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
}"
x-text="job.status.replace('_', ' ').toUpperCase()">
</span>
</td>
<td class="px-4 py-3 text-sm">
<div class="space-y-1">
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
</div>
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
<span x-text="job.error_count"></span> errors
</div>
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
Total: <span x-text="job.total_processed"></span>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="calculateDuration(job)"></span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<button
@click="viewJobDetails(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View Details"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
<button
x-show="job.status === 'processing' || job.status === 'pending'"
@click="refreshJobStatus(job.id)"
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Refresh Status"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
<!-- Pagination -->
{# noqa: FE-001 - Custom pagination with text buttons and totalJobs variable #}
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-400">
Showing <span x-text="((page - 1) * limit) + 1"></span> to
<span x-text="Math.min(page * limit, totalJobs)"></span> of
<span x-text="totalJobs"></span> jobs
</div>
<div class="flex space-x-2">
<button
@click="previousPage()"
:disabled="page === 1"
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
@click="nextPage()"
:disabled="page * limit >= totalJobs"
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
{# noqa: FE-004 - Custom modal with different field names (imported_count vs imported) #}
<!-- Job Details Modal -->
<div x-show="showJobModal"
x-cloak
@click.away="closeJobModal()"
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div @click.away="closeJobModal()"
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 transform translate-y-1/2"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform translate-y-1/2">
<!-- Modal Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Import Job Details
</h3>
<button @click="closeJobModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('close', 'w-5 h-5')"></span>
</button>
</div>
<!-- Modal Content -->
<div x-show="selectedJob" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Job ID</p>
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.id"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Marketplace</p>
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.marketplace"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="{
'text-green-700 bg-green-100': selectedJob?.status === 'completed',
'text-blue-700 bg-blue-100': selectedJob?.status === 'processing',
'text-yellow-700 bg-yellow-100': selectedJob?.status === 'pending',
'text-red-700 bg-red-100': selectedJob?.status === 'failed',
'text-orange-700 bg-orange-100': selectedJob?.status === 'completed_with_errors'
}"
x-text="selectedJob?.status.replace('_', ' ').toUpperCase()">
</span>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Source URL</p>
<p class="text-sm text-gray-900 dark:text-gray-100 truncate" x-text="selectedJob?.source_url" :title="selectedJob?.source_url"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Imported</p>
<p class="text-sm text-green-600 dark:text-green-400" x-text="selectedJob?.imported_count"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Updated</p>
<p class="text-sm text-blue-600 dark:text-blue-400" x-text="selectedJob?.updated_count"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJob?.error_count"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Processed</p>
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.total_processed"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Started At</p>
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.started_at ? formatDate(selectedJob.started_at) : 'Not started'"></p>
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed At</p>
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.completed_at ? formatDate(selectedJob.completed_at) : 'Not completed'"></p>
</div>
</div>
<!-- Error Details -->
<div x-show="selectedJob?.error_details?.length > 0" class="mt-4">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg max-h-48 overflow-y-auto">
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap" x-text="JSON.stringify(selectedJob?.error_details, null, 2)"></pre>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-end mt-6">
<button
@click="closeJobModal()"
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-700 hover:border-gray-500 focus:outline-none"
>
Close
</button>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,383 @@
{# app/templates/vendor/onboarding.html #}
{# standalone #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="vendorOnboarding()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to Wizamart - Setup Your Account</title>
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<div class="min-h-screen p-6" x-cloak>
<!-- Header -->
<div class="max-w-4xl mx-auto mb-8">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-lg bg-purple-600 flex items-center justify-center">
<span class="text-white font-bold text-xl">W</span>
</div>
<span class="text-xl font-semibold text-gray-800 dark:text-white">Wizamart</span>
</div>
<!-- Logout Button -->
<button @click="handleLogout()"
class="mr-4 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
Logout
</button>
<!-- Language Selector -->
{# noqa: FE-006 - Custom language selector with flags, not suited for dropdown macro #}
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="flex items-center space-x-2 px-3 py-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
<span x-text="languageFlags[lang]"></span>
<span class="text-sm text-gray-700 dark:text-gray-300 hidden sm:inline" x-text="languageNames[lang]"></span>
<span class="w-4 h-4 text-gray-500" x-html="$icon('chevron-down', 'w-4 h-4')"></span>
</button>
<div x-show="open" @click.away="open = false" x-cloak
class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 z-50">
<template x-for="langCode in availableLanguages" :key="langCode">
<button @click="setLang(langCode); open = false"
class="w-full flex items-center space-x-2 px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-600 first:rounded-t-lg last:rounded-b-lg"
:class="{ 'bg-purple-50 dark:bg-purple-900/20': lang === langCode }">
<span x-text="languageFlags[langCode]"></span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="languageNames[langCode]"></span>
</button>
</template>
</div>
</div>
</div>
</div>
<!-- Progress Indicator -->
<div class="max-w-4xl mx-auto mb-8 px-2 sm:px-0">
<div class="flex items-center justify-between">
<template x-for="(step, index) in steps" :key="step.id">
<div class="flex items-center" :class="{ 'flex-1': index < steps.length - 1 }">
<!-- Step Circle -->
<div class="flex flex-col items-center">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full flex items-center justify-center text-xs sm:text-sm font-semibold transition-all duration-200"
:class="{
'bg-purple-600 text-white': isStepCompleted(step.id) || currentStep === step.id,
'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400': !isStepCompleted(step.id) && currentStep !== step.id
}">
<template x-if="isStepCompleted(step.id)">
<span class="w-4 h-4 sm:w-5 sm:h-5" x-html="$icon('check', 'w-4 h-4 sm:w-5 sm:h-5')"></span>
</template>
<template x-if="!isStepCompleted(step.id)">
<span x-text="index + 1"></span>
</template>
</div>
<span class="mt-2 text-xs font-medium text-gray-600 dark:text-gray-400 text-center w-16 sm:w-24 hidden sm:block"
x-text="step.title"></span>
</div>
<!-- Connector Line -->
<template x-if="index < steps.length - 1">
<div class="flex-1 h-1 mx-1 sm:mx-4 rounded"
:class="{
'bg-purple-600': isStepCompleted(step.id),
'bg-gray-200 dark:bg-gray-700': !isStepCompleted(step.id)
}"></div>
</template>
</div>
</template>
</div>
</div>
<!-- Main Content Card -->
<div class="max-w-4xl mx-auto bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center">
<span class="inline-block w-8 h-8 text-purple-600" x-html="$icon('spinner', 'w-8 h-8 animate-spin')"></span>
<p class="mt-4 text-gray-600 dark:text-gray-400" x-text="t('loading')"></p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="p-6">
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4 text-center">
<p class="text-red-600 dark:text-red-400" x-text="error"></p>
<button @click="loadStatus()" class="mt-4 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700" x-text="t('buttons.retry')">
</button>
</div>
</div>
<!-- Step Content -->
<div x-show="!loading && !error">
<!-- Step 1: Company Profile -->
<div x-show="currentStep === 'company_profile'" x-transition>
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white" x-text="t('step1.title')"></h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400" x-text="t('step1.description')"></p>
</div>
<div class="p-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.company_name')"></label>
<input type="text" x-model="formData.company_name"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.brand_name')"></label>
<input type="text" x-model="formData.brand_name"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.description_label')"></label>
<textarea x-model="formData.description" rows="3" :placeholder="t('step1.description_placeholder')"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.contact_email')"></label>
<input type="email" x-model="formData.contact_email"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.contact_phone')"></label>
<input type="tel" x-model="formData.contact_phone"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.website')"></label>
<input type="url" x-model="formData.website"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.tax_number')"></label>
<input type="text" x-model="formData.tax_number" :placeholder="t('step1.tax_number_placeholder')"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.business_address')"></label>
<textarea x-model="formData.business_address" rows="2"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.default_language')"></label>
<select x-model="formData.default_language"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500">
<option value="fr">Français</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="lb">Lëtzebuergesch</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step1.dashboard_language')"></label>
<select x-model="formData.dashboard_language"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500">
<option value="fr">Français</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="lb">Lëtzebuergesch</option>
</select>
</div>
</div>
</div>
</div>
<!-- Step 2: Letzshop API -->
<div x-show="currentStep === 'letzshop_api'" x-transition>
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white" x-text="t('step2.title')"></h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400" x-text="t('step2.description')"></p>
</div>
<div class="p-6 space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step2.api_key')"></label>
<input type="password" x-model="formData.api_key" :placeholder="t('step2.api_key_placeholder')"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-text="t('step2.api_key_help')"></span> (<a href="mailto:support@letzshop.lu" class="text-purple-600 hover:underline">support@letzshop.lu</a>)
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step2.shop_slug')"></label>
<div class="mt-1 flex rounded-md shadow-sm">
<span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-sm">
letzshop.lu/.../vendors/
</span>
<input type="text" x-model="formData.shop_slug" placeholder="your-shop-name"
class="flex-1 block w-full rounded-none rounded-r-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-purple-500 focus:ring-purple-500" />
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-text="t('step2.shop_slug_help')"></p>
</div>
<div class="flex items-center space-x-4">
<button @click="testLetzshopApi()" :disabled="saving || !formData.api_key || !formData.shop_slug"
class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!testing" x-text="t('step2.test_connection')"></span>
<span x-show="testing" class="flex items-center">
<span class="w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
<span x-text="t('step2.testing')"></span>
</span>
</button>
<span x-show="connectionStatus === 'success'" class="text-green-600 dark:text-green-400 text-sm flex items-center">
<span class="w-4 h-4 mr-1" x-html="$icon('check', 'w-4 h-4')"></span>
<span x-text="t('step2.connection_success')"></span>
</span>
<span x-show="connectionStatus === 'failed'" class="text-red-600 dark:text-red-400 text-sm flex items-center">
<span class="w-4 h-4 mr-1" x-html="$icon('x-mark', 'w-4 h-4')"></span>
<span x-text="connectionError || t('step2.connection_failed')"></span>
</span>
</div>
</div>
</div>
<!-- Step 3: Product Import -->
<div x-show="currentStep === 'product_import'" x-transition>
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white" x-text="t('step3.title')"></h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400" x-text="t('step3.description')"></p>
</div>
<div class="p-6 space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step3.csv_url_fr')"></label>
<input type="url" x-model="formData.csv_url_fr" placeholder="https://..."
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-text="t('step3.csv_url_help')"></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step3.csv_url_en') + ' (optional)'"></label>
<input type="url" x-model="formData.csv_url_en" placeholder="https://..."
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" x-text="t('step3.csv_url_de') + ' (optional)'"></label>
<input type="url" x-model="formData.csv_url_de" placeholder="https://..."
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Letzshop Feed Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400" x-text="t('step3.default_tax_rate')"></label>
<select x-model="formData.default_tax_rate"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500">
<option value="17">17% (Standard)</option>
<option value="14">14% (Intermediate)</option>
<option value="8">8% (Reduced)</option>
<option value="3">3% (Super-reduced)</option>
<option value="0">0% (Exempt)</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400" x-text="t('step3.delivery_method')"></label>
<select x-model="formData.delivery_method"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500">
<option value="package_delivery" x-text="t('step3.delivery_package')"></option>
<option value="self_collect" x-text="t('step3.delivery_pickup')"></option>
<option value="nationwide">Nationwide</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400" x-text="t('step3.preorder_days')"></label>
{# noqa: FE-008 - Simple number input, not a quantity stepper pattern #}
<input type="number" x-model="formData.preorder_days" min="0" max="30"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-text="t('step3.preorder_days_help')"></p>
</div>
</div>
</div>
</div>
</div>
<!-- Step 4: Order Sync -->
<div x-show="currentStep === 'order_sync'" x-transition>
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white" x-text="t('step4.title')"></h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400" x-text="t('step4.description')"></p>
</div>
<div class="p-6">
<!-- Before Sync -->
<div x-show="!syncJobId">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" x-text="t('step4.days_back')"></label>
<select x-model="formData.days_back"
class="block w-48 rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-purple-500 focus:ring-purple-500">
<option value="30">30 <span x-text="t('step4.days')"></span></option>
<option value="60">60 <span x-text="t('step4.days')"></span></option>
<option value="90">90 <span x-text="t('step4.days')"></span></option>
<option value="180">180 <span x-text="t('step4.days')"></span></option>
<option value="365">365 <span x-text="t('step4.days')"></span></option>
</select>
</div>
<button @click="startOrderSync()" :disabled="saving"
class="px-6 py-3 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50"
x-text="saving ? t('step4.importing') : t('step4.start_import')">
</button>
</div>
<!-- During Sync -->
<div x-show="syncJobId && !syncComplete" class="text-center py-8">
<div class="mb-4">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
<div class="bg-purple-600 h-4 rounded-full transition-all duration-500"
:style="{ width: syncProgress + '%' }"></div>
</div>
</div>
<p class="text-lg font-medium text-gray-800 dark:text-white">
<span x-text="syncProgress"></span>% Complete
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="syncPhase"></p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
<span x-text="ordersImported"></span> <span x-text="t('step4.orders_imported')"></span>
</p>
</div>
<!-- After Sync -->
<div x-show="syncComplete" class="text-center py-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
</div>
<p class="text-lg font-medium text-gray-800 dark:text-white" x-text="t('step4.import_complete')"></p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
<span x-text="ordersImported"></span> <span x-text="t('step4.orders_imported')"></span>.
</p>
</div>
</div>
</div>
<!-- Footer Actions -->
<div class="p-6 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button x-show="currentStepIndex > 0 && !syncJobId"
@click="goToPreviousStep()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
x-text="t('buttons.back')">
</button>
<div x-show="currentStepIndex === 0"></div>
<button x-show="currentStep !== 'order_sync' || syncComplete"
@click="saveAndContinue()" :disabled="saving"
class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
<span x-show="!saving">
<span x-text="currentStep === 'order_sync' && syncComplete ? t('buttons.complete') : t('buttons.save_continue')"></span>
</span>
<span x-show="saving" class="flex items-center">
<span class="w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
<span x-text="t('buttons.saving')"></span>
</span>
</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<script src="{{ url_for('marketplace_static', path='vendor/js/onboarding.js') }}"></script>
</body>
</html>