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:
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
269
app/modules/marketplace/routes/api/public.py
Normal file
269
app/modules/marketplace/routes/api/public.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
243
app/modules/marketplace/routes/pages/admin.py
Normal file
243
app/modules/marketplace/routes/pages/admin.py
Normal 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",
|
||||
),
|
||||
)
|
||||
41
app/modules/marketplace/routes/pages/public.py
Normal file
41
app/modules/marketplace/routes/pages/public.py
Normal 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,
|
||||
)
|
||||
146
app/modules/marketplace/routes/pages/vendor.py
Normal file
146
app/modules/marketplace/routes/pages/vendor.py
Normal 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),
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
664
app/modules/marketplace/services/onboarding_service.py
Normal file
664
app/modules/marketplace/services/onboarding_service.py
Normal 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)
|
||||
649
app/modules/marketplace/services/platform_signup_service.py
Normal file
649
app/modules/marketplace/services/platform_signup_service.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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 →
|
||||
</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 →
|
||||
</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 %}
|
||||
263
app/modules/marketplace/templates/marketplace/admin/imports.html
Normal file
263
app/modules/marketplace/templates/marketplace/admin/imports.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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") }} →
|
||||
</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 %}
|
||||
686
app/modules/marketplace/templates/marketplace/vendor/letzshop.html
vendored
Normal file
686
app/modules/marketplace/templates/marketplace/vendor/letzshop.html
vendored
Normal 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 %}
|
||||
382
app/modules/marketplace/templates/marketplace/vendor/marketplace.html
vendored
Normal file
382
app/modules/marketplace/templates/marketplace/vendor/marketplace.html
vendored
Normal 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 %}
|
||||
383
app/modules/marketplace/templates/marketplace/vendor/onboarding.html
vendored
Normal file
383
app/modules/marketplace/templates/marketplace/vendor/onboarding.html
vendored
Normal 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>
|
||||
Reference in New Issue
Block a user