Multitenant implementation with custom Domain, theme per vendor

This commit is contained in:
2025-10-26 23:49:29 +01:00
parent c88775134d
commit 1e0cbf5927
24 changed files with 3470 additions and 624 deletions

View File

@@ -6,6 +6,7 @@ This module combines all admin-related API endpoints:
- Authentication (login/logout)
- Vendor management (CRUD, bulk operations)
- Vendor domains management (custom domains, DNS verification)
- Vendor themes management (theme editor, presets)
- User management (status, roles)
- Dashboard and statistics
- Marketplace monitoring
@@ -22,6 +23,7 @@ from . import (
auth,
vendors,
vendor_domains,
vendor_themes,
users,
dashboard,
marketplace,
@@ -54,6 +56,9 @@ router.include_router(vendors.router, tags=["admin-vendors"])
# Include vendor domains management endpoints
router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
# Include vendor themes management endpoints
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
# ============================================================================
# User Management

View File

@@ -17,6 +17,7 @@ Routes:
- GET /vendors/{vendor_code} → Vendor details (auth required)
- GET /vendors/{vendor_code}/edit → Edit vendor form (auth required)
- GET /vendors/{vendor_code}/domains → Vendor domains management (auth required)
- GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required)
- GET /users → User management page (auth required)
- GET /imports → Import history page (auth required)
- GET /settings → Settings page (auth required)
@@ -205,6 +206,7 @@ async def admin_vendor_theme_page(
):
"""
Render vendor theme customization page.
Allows admins to customize colors, fonts, layout, and branding.
"""
return templates.TemplateResponse(
"admin/vendor-theme.html",

View File

@@ -0,0 +1,326 @@
# app/api/v1/admin/vendor_themes.py
"""
Vendor theme management endpoints for admin.
These endpoints allow admins to:
- View vendor themes
- Apply theme presets
- Customize theme settings
- Reset themes to default
All operations use the service layer for business logic.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user, get_db
from app.services.vendor_theme_service import vendor_theme_service
from app.exceptions.vendor import VendorNotFoundException
from app.exceptions.vendor_theme import (
VendorThemeNotFoundException,
ThemePresetNotFoundException,
ThemeValidationException,
ThemeOperationException,
InvalidColorFormatException,
InvalidFontFamilyException
)
from models.database.user import User
from models.schema.vendor_theme import (
VendorThemeResponse,
VendorThemeUpdate,
ThemePresetResponse,
ThemePresetListResponse
)
router = APIRouter(prefix="/vendor-themes")
logger = logging.getLogger(__name__)
# ============================================================================
# PRESET ENDPOINTS
# ============================================================================
@router.get("/presets", response_model=ThemePresetListResponse)
async def get_theme_presets(
current_admin: User = Depends(get_current_admin_user)
):
"""
Get all available theme presets with preview information.
Returns list of presets that can be applied to vendor themes.
Each preset includes color palette, fonts, and layout configuration.
**Permissions:** Admin only
**Returns:**
- List of available theme presets with preview data
"""
logger.info("Getting theme presets")
try:
presets = vendor_theme_service.get_available_presets()
return {"presets": presets}
except Exception as e:
logger.error(f"Failed to get theme presets: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve theme presets"
)
# ============================================================================
# THEME RETRIEVAL
# ============================================================================
@router.get("/{vendor_code}", response_model=VendorThemeResponse)
async def get_vendor_theme(
vendor_code: str = Path(..., description="Vendor code"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
"""
Get theme configuration for a vendor.
Returns the vendor's custom theme if exists, otherwise returns default theme.
**Path Parameters:**
- `vendor_code`: Vendor code (e.g., VENDOR001)
**Permissions:** Admin only
**Returns:**
- Complete theme configuration including colors, fonts, layout, and branding
**Errors:**
- `404`: Vendor not found
- `500`: Internal server error
"""
logger.info(f"Getting theme for vendor: {vendor_code}")
try:
theme = vendor_theme_service.get_theme(db, vendor_code)
return theme
except VendorNotFoundException:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vendor '{vendor_code}' not found"
)
except Exception as e:
logger.error(f"Failed to get theme for vendor {vendor_code}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve theme"
)
# ============================================================================
# THEME UPDATE
# ============================================================================
@router.put("/{vendor_code}", response_model=VendorThemeResponse)
async def update_vendor_theme(
vendor_code: str = Path(..., description="Vendor code"),
theme_data: VendorThemeUpdate = None,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
"""
Update or create theme for a vendor.
Accepts partial updates - only provided fields are updated.
If vendor has no theme, a new one is created.
**Path Parameters:**
- `vendor_code`: Vendor code (e.g., VENDOR001)
**Request Body:**
- `theme_name`: Optional theme name
- `colors`: Optional color palette (primary, secondary, accent, etc.)
- `fonts`: Optional font settings (heading, body)
- `layout`: Optional layout settings (style, header, product_card)
- `branding`: Optional branding assets (logo, favicon, etc.)
- `custom_css`: Optional custom CSS rules
- `social_links`: Optional social media links
**Permissions:** Admin only
**Returns:**
- Updated theme configuration
**Errors:**
- `404`: Vendor not found
- `422`: Validation error (invalid colors, fonts, or layout values)
- `500`: Internal server error
"""
logger.info(f"Updating theme for vendor: {vendor_code}")
try:
theme = vendor_theme_service.update_theme(db, vendor_code, theme_data)
return theme.to_dict()
except VendorNotFoundException:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vendor '{vendor_code}' not found"
)
except (ThemeValidationException, InvalidColorFormatException, InvalidFontFamilyException) as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=e.message
)
except ThemeOperationException as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=e.message
)
except Exception as e:
logger.error(f"Failed to update theme for vendor {vendor_code}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update theme"
)
# ============================================================================
# PRESET APPLICATION
# ============================================================================
@router.post("/{vendor_code}/preset/{preset_name}")
async def apply_theme_preset(
vendor_code: str = Path(..., description="Vendor code"),
preset_name: str = Path(..., description="Preset name"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
"""
Apply a theme preset to a vendor.
Replaces the vendor's current theme with the selected preset.
Available presets can be retrieved from the `/presets` endpoint.
**Path Parameters:**
- `vendor_code`: Vendor code (e.g., VENDOR001)
- `preset_name`: Name of preset to apply (e.g., 'modern', 'classic')
**Available Presets:**
- `default`: Clean and professional
- `modern`: Contemporary tech-inspired
- `classic`: Traditional and trustworthy
- `minimal`: Ultra-clean black and white
- `vibrant`: Bold and energetic
- `elegant`: Sophisticated gray tones
- `nature`: Fresh and eco-friendly
**Permissions:** Admin only
**Returns:**
- Success message and applied theme configuration
**Errors:**
- `404`: Vendor or preset not found
- `500`: Internal server error
"""
logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}")
try:
theme = vendor_theme_service.apply_theme_preset(db, vendor_code, preset_name)
return {
"message": f"Applied {preset_name} preset successfully",
"theme": theme.to_dict()
}
except VendorNotFoundException:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vendor '{vendor_code}' not found"
)
except ThemePresetNotFoundException as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=e.message
)
except ThemeOperationException as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=e.message
)
except Exception as e:
logger.error(f"Failed to apply preset to vendor {vendor_code}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to apply preset"
)
# ============================================================================
# THEME DELETION
# ============================================================================
@router.delete("/{vendor_code}")
async def delete_vendor_theme(
vendor_code: str = Path(..., description="Vendor code"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
"""
Delete custom theme for a vendor.
Removes the vendor's custom theme. After deletion, the vendor
will revert to using the default platform theme.
**Path Parameters:**
- `vendor_code`: Vendor code (e.g., VENDOR001)
**Permissions:** Admin only
**Returns:**
- Success message
**Errors:**
- `404`: Vendor not found or vendor has no custom theme
- `500`: Internal server error
"""
logger.info(f"Deleting theme for vendor: {vendor_code}")
try:
result = vendor_theme_service.delete_theme(db, vendor_code)
return result
except VendorNotFoundException:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vendor '{vendor_code}' not found"
)
except VendorThemeNotFoundException:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Vendor '{vendor_code}' has no custom theme"
)
except ThemeOperationException as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=e.message
)
except Exception as e:
logger.error(f"Failed to delete theme for vendor {vendor_code}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete theme"
)

View File

@@ -113,7 +113,6 @@ def create_vendor_with_owner(
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
theme_config=vendor.theme_config or {},
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
@@ -197,7 +196,6 @@ def get_vendor_details(
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
theme_config=vendor.theme_config or {},
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
@@ -250,7 +248,6 @@ def update_vendor(
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
theme_config=vendor.theme_config or {},
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
@@ -354,7 +351,6 @@ def toggle_vendor_verification(
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
theme_config=vendor.theme_config or {},
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,
@@ -406,7 +402,6 @@ def toggle_vendor_status(
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
theme_config=vendor.theme_config or {},
is_active=vendor.is_active,
is_verified=vendor.is_verified,
created_at=vendor.created_at,

View File

@@ -37,7 +37,6 @@ def get_vendor_settings(
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
"theme_config": vendor.theme_config,
"is_active": vendor.is_active,
"is_verified": vendor.is_verified,
}
@@ -72,24 +71,3 @@ def update_marketplace_settings(
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
}
@router.put("/theme")
def update_theme_settings(
theme_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update vendor theme configuration."""
if not vendor_service.can_update_vendor(vendor, current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions")
vendor.theme_config = theme_config
db.commit()
db.refresh(vendor)
return {
"message": "Theme settings updated successfully",
"theme_config": vendor.theme_config,
}

311
app/core/theme_presets.py Normal file
View File

@@ -0,0 +1,311 @@
# app/core/theme_presets.py
"""
Theme presets for vendor shops.
Presets define default color schemes, fonts, and layouts that vendors can choose from.
Each preset provides a complete theme configuration that can be customized further.
"""
from models.database.vendor_theme import VendorTheme
THEME_PRESETS = {
"default": {
"colors": {
"primary": "#6366f1", # Indigo
"secondary": "#8b5cf6", # Purple
"accent": "#ec4899", # Pink
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#e5e7eb" # Gray-200
},
"fonts": {
"heading": "Inter, sans-serif",
"body": "Inter, sans-serif"
},
"layout": {
"style": "grid",
"header": "fixed",
"product_card": "modern"
}
},
"modern": {
"colors": {
"primary": "#6366f1", # Indigo - Modern tech look
"secondary": "#8b5cf6", # Purple
"accent": "#ec4899", # Pink
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#e5e7eb" # Gray-200
},
"fonts": {
"heading": "Inter, sans-serif",
"body": "Inter, sans-serif"
},
"layout": {
"style": "grid",
"header": "fixed",
"product_card": "modern"
}
},
"classic": {
"colors": {
"primary": "#1e40af", # Dark blue - Traditional
"secondary": "#7c3aed", # Purple
"accent": "#dc2626", # Red
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#d1d5db" # Gray-300
},
"fonts": {
"heading": "Georgia, serif",
"body": "Arial, sans-serif"
},
"layout": {
"style": "list",
"header": "static",
"product_card": "classic"
}
},
"minimal": {
"colors": {
"primary": "#000000", # Black - Ultra minimal
"secondary": "#404040", # Dark gray
"accent": "#666666", # Medium gray
"background": "#ffffff", # White
"text": "#000000", # Black
"border": "#e5e7eb" # Light gray
},
"fonts": {
"heading": "Helvetica, sans-serif",
"body": "Helvetica, sans-serif"
},
"layout": {
"style": "grid",
"header": "transparent",
"product_card": "minimal"
}
},
"vibrant": {
"colors": {
"primary": "#f59e0b", # Orange - Bold & energetic
"secondary": "#ef4444", # Red
"accent": "#8b5cf6", # Purple
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#fbbf24" # Yellow
},
"fonts": {
"heading": "Poppins, sans-serif",
"body": "Open Sans, sans-serif"
},
"layout": {
"style": "masonry",
"header": "fixed",
"product_card": "modern"
}
},
"elegant": {
"colors": {
"primary": "#6b7280", # Gray - Sophisticated
"secondary": "#374151", # Dark gray
"accent": "#d97706", # Amber
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#e5e7eb" # Gray-200
},
"fonts": {
"heading": "Playfair Display, serif",
"body": "Lato, sans-serif"
},
"layout": {
"style": "grid",
"header": "fixed",
"product_card": "classic"
}
},
"nature": {
"colors": {
"primary": "#059669", # Green - Natural & eco
"secondary": "#10b981", # Emerald
"accent": "#f59e0b", # Amber
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#d1fae5" # Light green
},
"fonts": {
"heading": "Montserrat, sans-serif",
"body": "Open Sans, sans-serif"
},
"layout": {
"style": "grid",
"header": "fixed",
"product_card": "modern"
}
}
}
def get_preset(preset_name: str) -> dict:
"""
Get a theme preset by name.
Args:
preset_name: Name of the preset (e.g., 'modern', 'classic')
Returns:
dict: Theme configuration
Raises:
ValueError: If preset name is unknown
"""
if preset_name not in THEME_PRESETS:
available = ", ".join(THEME_PRESETS.keys())
raise ValueError(f"Unknown preset: {preset_name}. Available: {available}")
return THEME_PRESETS[preset_name]
def apply_preset(theme: VendorTheme, preset_name: str) -> VendorTheme:
"""
Apply a preset to a vendor theme.
Args:
theme: VendorTheme instance to update
preset_name: Name of the preset to apply
Returns:
VendorTheme: Updated theme instance
Raises:
ValueError: If preset name is unknown
Example:
theme = VendorTheme(vendor_id=1)
apply_preset(theme, "modern")
db.add(theme)
db.commit()
"""
preset = get_preset(preset_name)
# Set theme name
theme.theme_name = preset_name
# Apply colors (all of them!)
theme.colors = preset["colors"]
# Apply fonts
theme.font_family_heading = preset["fonts"]["heading"]
theme.font_family_body = preset["fonts"]["body"]
# Apply layout settings
theme.layout_style = preset["layout"]["style"]
theme.header_style = preset["layout"]["header"]
theme.product_card_style = preset["layout"]["product_card"]
# Mark as active
theme.is_active = True
return theme
def get_available_presets() -> list[str]:
"""
Get list of available preset names.
Returns:
list: Available preset names
"""
return list(THEME_PRESETS.keys())
def get_preset_preview(preset_name: str) -> dict:
"""
Get preview information for a preset (for UI display).
Args:
preset_name: Name of the preset
Returns:
dict: Preview info with colors, fonts, description
"""
preset = get_preset(preset_name)
descriptions = {
"default": "Clean and professional - perfect for getting started",
"modern": "Contemporary tech-inspired design with vibrant colors",
"classic": "Traditional and trustworthy with serif typography",
"minimal": "Ultra-clean black and white aesthetic",
"vibrant": "Bold and energetic with bright accent colors",
"elegant": "Sophisticated gray tones with refined typography",
"nature": "Fresh and eco-friendly green color palette"
}
return {
"name": preset_name,
"description": descriptions.get(preset_name, ""),
"primary_color": preset["colors"]["primary"],
"secondary_color": preset["colors"]["secondary"],
"accent_color": preset["colors"]["accent"],
"heading_font": preset["fonts"]["heading"],
"body_font": preset["fonts"]["body"],
"layout_style": preset["layout"]["style"],
}
def create_custom_preset(
colors: dict,
fonts: dict,
layout: dict,
name: str = "custom"
) -> dict:
"""
Create a custom preset from provided settings.
Args:
colors: Dict with primary, secondary, accent, background, text, border
fonts: Dict with heading and body fonts
layout: Dict with style, header, product_card
name: Name for the custom preset
Returns:
dict: Custom preset configuration
Example:
custom = create_custom_preset(
colors={"primary": "#ff0000", "secondary": "#00ff00", ...},
fonts={"heading": "Arial", "body": "Arial"},
layout={"style": "grid", "header": "fixed", "product_card": "modern"},
name="my_custom_theme"
)
"""
# Validate colors
required_colors = ["primary", "secondary", "accent", "background", "text", "border"]
for color_key in required_colors:
if color_key not in colors:
colors[color_key] = THEME_PRESETS["default"]["colors"][color_key]
# Validate fonts
if "heading" not in fonts:
fonts["heading"] = "Inter, sans-serif"
if "body" not in fonts:
fonts["body"] = "Inter, sans-serif"
# Validate layout
if "style" not in layout:
layout["style"] = "grid"
if "header" not in layout:
layout["header"] = "fixed"
if "product_card" not in layout:
layout["product_card"] = "modern"
return {
"colors": colors,
"fonts": fonts,
"layout": layout
}

View File

@@ -43,7 +43,7 @@ from .admin import (
BulkOperationException,
)
# Marketplace import jon exceptions
# Marketplace import job exceptions
from .marketplace_import_job import (
MarketplaceImportException,
ImportJobNotFoundException,
@@ -106,6 +106,18 @@ from .vendor_domain import (
UnauthorizedDomainAccessException,
)
# Vendor theme exceptions
from .vendor_theme import (
VendorThemeNotFoundException,
InvalidThemeDataException,
ThemePresetNotFoundException,
ThemeValidationException,
ThemePresetAlreadyAppliedException,
InvalidColorFormatException,
InvalidFontFamilyException,
ThemeOperationException,
)
# Customer exceptions
from .customer import (
CustomerNotFoundException,
@@ -235,6 +247,16 @@ __all__ = [
"MaxDomainsReachedException",
"UnauthorizedDomainAccessException",
# Vendor Theme
"VendorThemeNotFoundException",
"InvalidThemeDataException",
"ThemePresetNotFoundException",
"ThemeValidationException",
"ThemePresetAlreadyAppliedException",
"InvalidColorFormatException",
"InvalidFontFamilyException",
"ThemeOperationException",
# Product exceptions
"ProductNotFoundException",
"ProductAlreadyExistsException",
@@ -282,4 +304,4 @@ __all__ = [
"CannotModifySelfException",
"InvalidAdminActionException",
"BulkOperationException",
]
]

View File

@@ -0,0 +1,137 @@
# app/exceptions/vendor_theme.py
"""
Vendor theme management specific exceptions.
"""
from typing import Any, Dict, Optional
from .base import (
ResourceNotFoundException,
ConflictException,
ValidationException,
BusinessLogicException
)
class VendorThemeNotFoundException(ResourceNotFoundException):
"""Raised when a vendor theme is not found."""
def __init__(self, vendor_identifier: str):
super().__init__(
resource_type="VendorTheme",
identifier=vendor_identifier,
message=f"Theme for vendor '{vendor_identifier}' not found",
error_code="VENDOR_THEME_NOT_FOUND",
)
class InvalidThemeDataException(ValidationException):
"""Raised when theme data is invalid."""
def __init__(
self,
message: str = "Invalid theme data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_THEME_DATA"
class ThemePresetNotFoundException(ResourceNotFoundException):
"""Raised when a theme preset is not found."""
def __init__(self, preset_name: str, available_presets: Optional[list] = None):
details = {"preset_name": preset_name}
if available_presets:
details["available_presets"] = available_presets
super().__init__(
resource_type="ThemePreset",
identifier=preset_name,
message=f"Theme preset '{preset_name}' not found",
error_code="THEME_PRESET_NOT_FOUND",
)
self.details = details
class ThemeValidationException(ValidationException):
"""Raised when theme validation fails."""
def __init__(
self,
message: str = "Theme validation failed",
field: Optional[str] = None,
validation_errors: Optional[Dict[str, str]] = None,
):
details = {}
if validation_errors:
details["validation_errors"] = validation_errors
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "THEME_VALIDATION_FAILED"
class ThemePresetAlreadyAppliedException(BusinessLogicException):
"""Raised when trying to apply the same preset that's already active."""
def __init__(self, preset_name: str, vendor_code: str):
super().__init__(
message=f"Preset '{preset_name}' is already applied to vendor '{vendor_code}'",
error_code="THEME_PRESET_ALREADY_APPLIED",
details={
"preset_name": preset_name,
"vendor_code": vendor_code
},
)
class InvalidColorFormatException(ValidationException):
"""Raised when color format is invalid."""
def __init__(self, color_value: str, field: str):
super().__init__(
message=f"Invalid color format: {color_value}",
field=field,
details={"color_value": color_value},
)
self.error_code = "INVALID_COLOR_FORMAT"
class InvalidFontFamilyException(ValidationException):
"""Raised when font family is invalid."""
def __init__(self, font_value: str, field: str):
super().__init__(
message=f"Invalid font family: {font_value}",
field=field,
details={"font_value": font_value},
)
self.error_code = "INVALID_FONT_FAMILY"
class ThemeOperationException(BusinessLogicException):
"""Raised when theme operation fails."""
def __init__(
self,
operation: str,
vendor_code: str,
reason: str
):
super().__init__(
message=f"Theme operation '{operation}' failed for vendor '{vendor_code}': {reason}",
error_code="THEME_OPERATION_FAILED",
details={
"operation": operation,
"vendor_code": vendor_code,
"reason": reason
},
)

View File

@@ -184,7 +184,6 @@ class AdminService:
letzshop_csv_url_fr=vendor_data.letzshop_csv_url_fr,
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
theme_config=vendor_data.theme_config or {},
is_active=True,
is_verified=True,
)

View File

@@ -0,0 +1,516 @@
# app/services/vendor_theme_service.py
"""
Vendor Theme Service
Business logic for vendor theme management.
Handles theme CRUD operations, preset application, and validation.
"""
import logging
import re
from typing import Optional, Dict, List
from sqlalchemy.orm import Session
from models.database.vendor import Vendor
from models.database.vendor_theme import VendorTheme
from models.schema.vendor_theme import (
VendorThemeUpdate,
ThemePresetPreview
)
from app.exceptions.vendor import VendorNotFoundException
from app.exceptions.vendor_theme import (
VendorThemeNotFoundException,
InvalidThemeDataException,
ThemePresetNotFoundException,
ThemeValidationException,
InvalidColorFormatException,
InvalidFontFamilyException,
ThemePresetAlreadyAppliedException,
ThemeOperationException
)
from app.core.theme_presets import (
apply_preset,
get_available_presets,
get_preset_preview,
THEME_PRESETS
)
logger = logging.getLogger(__name__)
class VendorThemeService:
"""
Service for managing vendor themes.
This service handles:
- Theme retrieval and creation
- Theme updates and validation
- Preset application
- Default theme generation
"""
def __init__(self):
"""Initialize the vendor theme service."""
self.logger = logging.getLogger(__name__)
# ============================================================================
# VENDOR RETRIEVAL
# ============================================================================
def _get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor:
"""
Get vendor by code or raise exception.
Args:
db: Database session
vendor_code: Vendor code to lookup
Returns:
Vendor object
Raises:
VendorNotFoundException: If vendor not found
"""
vendor = db.query(Vendor).filter(
Vendor.vendor_code == vendor_code.upper()
).first()
if not vendor:
self.logger.warning(f"Vendor not found: {vendor_code}")
raise VendorNotFoundException(vendor_code, identifier_type="code")
return vendor
# ============================================================================
# THEME RETRIEVAL
# ============================================================================
def get_theme(self, db: Session, vendor_code: str) -> Dict:
"""
Get theme for vendor. Returns default if no custom theme exists.
Args:
db: Database session
vendor_code: Vendor code
Returns:
Theme dictionary
Raises:
VendorNotFoundException: If vendor not found
"""
self.logger.info(f"Getting theme for vendor: {vendor_code}")
# Verify vendor exists
vendor = self._get_vendor_by_code(db, vendor_code)
# Get theme
theme = db.query(VendorTheme).filter(
VendorTheme.vendor_id == vendor.id
).first()
if not theme:
self.logger.info(f"No custom theme for vendor {vendor_code}, returning default")
return self._get_default_theme()
return theme.to_dict()
def _get_default_theme(self) -> Dict:
"""
Get default theme configuration.
Returns:
Default theme dictionary
"""
return {
"theme_name": "default",
"colors": {
"primary": "#6366f1",
"secondary": "#8b5cf6",
"accent": "#ec4899",
"background": "#ffffff",
"text": "#1f2937",
"border": "#e5e7eb"
},
"fonts": {
"heading": "Inter, sans-serif",
"body": "Inter, sans-serif"
},
"branding": {
"logo": None,
"logo_dark": None,
"favicon": None,
"banner": None
},
"layout": {
"style": "grid",
"header": "fixed",
"product_card": "modern"
},
"social_links": {},
"custom_css": None,
"css_variables": {
"--color-primary": "#6366f1",
"--color-secondary": "#8b5cf6",
"--color-accent": "#ec4899",
"--color-background": "#ffffff",
"--color-text": "#1f2937",
"--color-border": "#e5e7eb",
"--font-heading": "Inter, sans-serif",
"--font-body": "Inter, sans-serif",
}
}
# ============================================================================
# THEME UPDATE
# ============================================================================
def update_theme(
self,
db: Session,
vendor_code: str,
theme_data: VendorThemeUpdate
) -> VendorTheme:
"""
Update or create theme for vendor.
Args:
db: Database session
vendor_code: Vendor code
theme_data: Theme update data
Returns:
Updated VendorTheme object
Raises:
VendorNotFoundException: If vendor not found
ThemeValidationException: If theme data invalid
ThemeOperationException: If update fails
"""
self.logger.info(f"Updating theme for vendor: {vendor_code}")
try:
# Verify vendor exists
vendor = self._get_vendor_by_code(db, vendor_code)
# Get or create theme
theme = db.query(VendorTheme).filter(
VendorTheme.vendor_id == vendor.id
).first()
if not theme:
self.logger.info(f"Creating new theme for vendor {vendor_code}")
theme = VendorTheme(vendor_id=vendor.id, is_active=True)
db.add(theme)
# Validate theme data before applying
self._validate_theme_data(theme_data)
# Update theme fields
self._apply_theme_updates(theme, theme_data)
# Commit changes
db.commit()
db.refresh(theme)
self.logger.info(f"Theme updated successfully for vendor {vendor_code}")
return theme
except (VendorNotFoundException, ThemeValidationException):
# Re-raise custom exceptions
raise
except Exception as e:
db.rollback()
self.logger.error(f"Failed to update theme for vendor {vendor_code}: {e}")
raise ThemeOperationException(
operation="update",
vendor_code=vendor_code,
reason=str(e)
)
def _apply_theme_updates(
self,
theme: VendorTheme,
theme_data: VendorThemeUpdate
) -> None:
"""
Apply theme updates to theme object.
Args:
theme: VendorTheme object to update
theme_data: Theme update data
"""
# Update theme name
if theme_data.theme_name:
theme.theme_name = theme_data.theme_name
# Update colors
if theme_data.colors:
theme.colors = theme_data.colors
# Update fonts
if theme_data.fonts:
if theme_data.fonts.get('heading'):
theme.font_family_heading = theme_data.fonts['heading']
if theme_data.fonts.get('body'):
theme.font_family_body = theme_data.fonts['body']
# Update branding
if theme_data.branding:
if theme_data.branding.get('logo') is not None:
theme.logo_url = theme_data.branding['logo']
if theme_data.branding.get('logo_dark') is not None:
theme.logo_dark_url = theme_data.branding['logo_dark']
if theme_data.branding.get('favicon') is not None:
theme.favicon_url = theme_data.branding['favicon']
if theme_data.branding.get('banner') is not None:
theme.banner_url = theme_data.branding['banner']
# Update layout
if theme_data.layout:
if theme_data.layout.get('style'):
theme.layout_style = theme_data.layout['style']
if theme_data.layout.get('header'):
theme.header_style = theme_data.layout['header']
if theme_data.layout.get('product_card'):
theme.product_card_style = theme_data.layout['product_card']
# Update custom CSS
if theme_data.custom_css is not None:
theme.custom_css = theme_data.custom_css
# Update social links
if theme_data.social_links:
theme.social_links = theme_data.social_links
# ============================================================================
# PRESET OPERATIONS
# ============================================================================
def apply_theme_preset(
self,
db: Session,
vendor_code: str,
preset_name: str
) -> VendorTheme:
"""
Apply a theme preset to vendor.
Args:
db: Database session
vendor_code: Vendor code
preset_name: Name of preset to apply
Returns:
Updated VendorTheme object
Raises:
VendorNotFoundException: If vendor not found
ThemePresetNotFoundException: If preset not found
ThemeOperationException: If application fails
"""
self.logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}")
try:
# Validate preset name
if preset_name not in THEME_PRESETS:
available = get_available_presets()
raise ThemePresetNotFoundException(preset_name, available)
# Verify vendor exists
vendor = self._get_vendor_by_code(db, vendor_code)
# Get or create theme
theme = db.query(VendorTheme).filter(
VendorTheme.vendor_id == vendor.id
).first()
if not theme:
self.logger.info(f"Creating new theme for vendor {vendor_code}")
theme = VendorTheme(vendor_id=vendor.id)
db.add(theme)
# Apply preset using helper function
apply_preset(theme, preset_name)
# Commit changes
db.commit()
db.refresh(theme)
self.logger.info(f"Preset '{preset_name}' applied successfully to vendor {vendor_code}")
return theme
except (VendorNotFoundException, ThemePresetNotFoundException):
# Re-raise custom exceptions
raise
except Exception as e:
db.rollback()
self.logger.error(f"Failed to apply preset to vendor {vendor_code}: {e}")
raise ThemeOperationException(
operation="apply_preset",
vendor_code=vendor_code,
reason=str(e)
)
def get_available_presets(self) -> List[ThemePresetPreview]:
"""
Get list of available theme presets.
Returns:
List of preset preview objects
"""
self.logger.debug("Getting available presets")
preset_names = get_available_presets()
presets = []
for name in preset_names:
preview = get_preset_preview(name)
presets.append(preview)
return presets
# ============================================================================
# THEME DELETION
# ============================================================================
def delete_theme(self, db: Session, vendor_code: str) -> Dict:
"""
Delete custom theme for vendor (reverts to default).
Args:
db: Database session
vendor_code: Vendor code
Returns:
Success message dictionary
Raises:
VendorNotFoundException: If vendor not found
VendorThemeNotFoundException: If no custom theme exists
ThemeOperationException: If deletion fails
"""
self.logger.info(f"Deleting theme for vendor: {vendor_code}")
try:
# Verify vendor exists
vendor = self._get_vendor_by_code(db, vendor_code)
# Get theme
theme = db.query(VendorTheme).filter(
VendorTheme.vendor_id == vendor.id
).first()
if not theme:
raise VendorThemeNotFoundException(vendor_code)
# Delete theme
db.delete(theme)
db.commit()
self.logger.info(f"Theme deleted for vendor {vendor_code}")
return {
"message": "Theme deleted successfully. Vendor will use default theme."
}
except (VendorNotFoundException, VendorThemeNotFoundException):
# Re-raise custom exceptions
raise
except Exception as e:
db.rollback()
self.logger.error(f"Failed to delete theme for vendor {vendor_code}: {e}")
raise ThemeOperationException(
operation="delete",
vendor_code=vendor_code,
reason=str(e)
)
# ============================================================================
# VALIDATION
# ============================================================================
def _validate_theme_data(self, theme_data: VendorThemeUpdate) -> None:
"""
Validate theme data before applying.
Args:
theme_data: Theme update data
Raises:
ThemeValidationException: If validation fails
InvalidColorFormatException: If color format invalid
InvalidFontFamilyException: If font family invalid
"""
# Validate colors
if theme_data.colors:
for color_key, color_value in theme_data.colors.items():
if not self._is_valid_color(color_value):
raise InvalidColorFormatException(color_value, color_key)
# Validate fonts
if theme_data.fonts:
for font_key, font_value in theme_data.fonts.items():
if not self._is_valid_font(font_value):
raise InvalidFontFamilyException(font_value, font_key)
# Validate layout values
if theme_data.layout:
valid_layouts = {
'style': ['grid', 'list', 'masonry'],
'header': ['fixed', 'static', 'transparent'],
'product_card': ['modern', 'classic', 'minimal']
}
for layout_key, layout_value in theme_data.layout.items():
if layout_key in valid_layouts:
if layout_value not in valid_layouts[layout_key]:
raise ThemeValidationException(
message=f"Invalid {layout_key} value: {layout_value}",
field=layout_key,
validation_errors={
layout_key: f"Must be one of: {', '.join(valid_layouts[layout_key])}"
}
)
def _is_valid_color(self, color: str) -> bool:
"""
Validate color format (hex color).
Args:
color: Color string to validate
Returns:
True if valid, False otherwise
"""
if not color:
return False
# Check for hex color format (#RGB or #RRGGBB)
hex_pattern = r'^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'
return bool(re.match(hex_pattern, color))
def _is_valid_font(self, font: str) -> bool:
"""
Validate font family format.
Args:
font: Font family string to validate
Returns:
True if valid, False otherwise
"""
if not font or len(font) < 3:
return False
# Basic validation - font should not be empty and should be reasonable length
return len(font) <= 200
# ============================================================================
# SERVICE INSTANCE
# ============================================================================
vendor_theme_service = VendorThemeService()

View File

@@ -22,11 +22,11 @@
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar (server-side included) -->
{% include 'partials/sidebar.html' %}
{% include 'admin/partials/sidebar.html' %}
<div class="flex flex-col flex-1 w-full">
<!-- Header (server-side included) -->
{% include 'partials/header.html' %}
{% include 'admin/partials/header.html' %}
<!-- Main Content -->
<main class="h-full overflow-y-auto">

View File

@@ -1,4 +1,4 @@
<!-- app/templates/partials/header.html -->
<!-- app/templates/admin/partials/header.html -->
<!-- Top header bar with search, theme toggle, notifications, profile -->
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
<div class="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">

View File

@@ -1,4 +1,4 @@
{# app/templates/partials/sidebar.html #}
{# app/templates/admin/partials/sidebar.html #}
<!-- Desktop sidebar -->
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
<div class="py-4 text-gray-500 dark:text-gray-400">

View File

@@ -1,290 +1,459 @@
{# app/templates/admin/vendor-theme.html #}
{% extends "admin/base.html" %}
{% block title %}Vendor Theme - {{ vendor_code }}{% endblock %}
{% block title %}Theme Editor - {{ vendor_code }}{% endblock %}
{% block alpine_data %}vendorThemeData(){% endblock %}
{# ✅ CRITICAL: Binds to adminVendorTheme() function in vendor-theme.js #}
{% block alpine_data %}adminVendorTheme(){% endblock %}
{% block content %}
<div class="container px-6 mx-auto grid">
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Vendor Theme
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Customize appearance for <span x-text="vendor?.name"></span>
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Theme Editor
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Customize appearance for <span x-text="vendor?.name || '...'"></span>
</p>
</div>
<div class="flex space-x-2">
<a :href="`/admin/vendors/${vendorCode}`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Vendor
</a>
</div>
</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 animate-spin')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading theme...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
<p x-text="error"></p>
</div>
<!-- Main Content -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Theme Configuration Form (2 columns) -->
<div class="md:col-span-2 space-y-6">
<!-- Theme Presets -->
<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">
<span x-html="$icon('palette', 'inline w-5 h-5 mr-2')"></span>
Choose a Preset
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Start with a pre-designed theme, then customize it to match your brand.
</p>
</div>
<div class="flex space-x-2">
<a :href="`/admin/vendors/${vendorCode}`"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Vendor
</a>
</div>
</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 animate-spin')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading theme...</p>
</div>
<!-- Main Content -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Theme Configuration Form (2 columns) -->
<div class="md:col-span-2">
<div class="px-4 py-3 mb-8 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">
Theme Configuration
</h3>
<!-- Theme Preset Selector -->
<div class="mb-6 p-4 bg-purple-50 dark:bg-purple-900 dark:bg-opacity-20 rounded-lg border border-purple-200 dark:border-purple-800">
<h4 class="text-sm font-semibold text-purple-800 dark:text-purple-200 mb-2">
Quick Start: Choose a Preset
</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<button @click="applyPreset('modern')"
class="px-3 py-2 text-sm font-medium text-purple-700 bg-white border border-purple-300 rounded-lg hover:bg-purple-50 dark:bg-gray-800 dark:text-purple-300 dark:border-purple-700">
Modern
</button>
<button @click="applyPreset('classic')"
class="px-3 py-2 text-sm font-medium text-blue-700 bg-white border border-blue-300 rounded-lg hover:bg-blue-50 dark:bg-gray-800 dark:text-blue-300 dark:border-blue-700">
Classic
</button>
<button @click="applyPreset('minimal')"
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700">
Minimal
</button>
<button @click="applyPreset('vibrant')"
class="px-3 py-2 text-sm font-medium text-orange-700 bg-white border border-orange-300 rounded-lg hover:bg-orange-50 dark:bg-gray-800 dark:text-orange-300 dark:border-orange-700">
Vibrant
</button>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Default Preset -->
<button @click="applyPreset('default')"
:disabled="saving"
:class="themeData.theme_name === 'default' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-purple-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-indigo-500"></div>
<div class="w-4 h-4 rounded bg-purple-500"></div>
<div class="w-4 h-4 rounded bg-pink-500"></div>
</div>
</div>
<p class="text-gray-700 dark:text-gray-300">Default</p>
</button>
<!-- Colors Section -->
<div class="mb-6">
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Colors</h4>
<div class="grid gap-4 md:grid-cols-2">
<!-- Primary Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Primary Color</span>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.primary"
class="h-10 w-20 border border-gray-300 rounded dark:border-gray-600 cursor-pointer">
<input type="text"
x-model="themeData.colors.primary"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input">
</div>
</label>
<!-- Secondary Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Secondary Color</span>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.secondary"
class="h-10 w-20 border border-gray-300 rounded dark:border-gray-600 cursor-pointer">
<input type="text"
x-model="themeData.colors.secondary"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input">
</div>
</label>
<!-- Accent Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Accent Color</span>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.accent"
class="h-10 w-20 border border-gray-300 rounded dark:border-gray-600 cursor-pointer">
<input type="text"
x-model="themeData.colors.accent"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input">
</div>
</label>
<!-- Modern Preset -->
<button @click="applyPreset('modern')"
:disabled="saving"
:class="themeData.theme_name === 'modern' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-purple-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-indigo-500"></div>
<div class="w-4 h-4 rounded bg-purple-600"></div>
<div class="w-4 h-4 rounded bg-pink-500"></div>
</div>
</div>
<p class="text-gray-700 dark:text-gray-300">Modern</p>
</button>
<!-- Typography Section -->
<div class="mb-6">
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Typography</h4>
<div class="grid gap-4 md:grid-cols-2">
<!-- Heading Font -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Heading Font</span>
<select x-model="themeData.fonts.heading"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
<option value="Inter, sans-serif">Inter</option>
<option value="Roboto, sans-serif">Roboto</option>
<option value="Poppins, sans-serif">Poppins</option>
<option value="Playfair Display, serif">Playfair Display</option>
<option value="Merriweather, serif">Merriweather</option>
<option value="Georgia, serif">Georgia</option>
</select>
</label>
<!-- Body Font -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Body Font</span>
<select x-model="themeData.fonts.body"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
<option value="Inter, sans-serif">Inter</option>
<option value="Open Sans, sans-serif">Open Sans</option>
<option value="Lato, sans-serif">Lato</option>
<option value="Source Sans Pro, sans-serif">Source Sans Pro</option>
<option value="Arial, sans-serif">Arial</option>
</select>
</label>
<!-- Classic Preset -->
<button @click="applyPreset('classic')"
:disabled="saving"
:class="themeData.theme_name === 'classic' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-blue-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-blue-800"></div>
<div class="w-4 h-4 rounded bg-purple-700"></div>
<div class="w-4 h-4 rounded bg-red-600"></div>
</div>
</div>
<p class="text-gray-700 dark:text-gray-300">Classic</p>
</button>
<!-- Layout Section -->
<div class="mb-6">
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Layout</h4>
<div class="grid gap-4 md:grid-cols-2">
<!-- Product Layout Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Product Layout</span>
<select x-model="themeData.layout.style"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
<option value="grid">Grid</option>
<option value="list">List</option>
<option value="masonry">Masonry</option>
</select>
</label>
<!-- Header Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Header Style</span>
<select x-model="themeData.layout.header"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
<option value="fixed">Fixed</option>
<option value="static">Static</option>
<option value="transparent">Transparent</option>
</select>
</label>
<!-- Minimal Preset -->
<button @click="applyPreset('minimal')"
:disabled="saving"
:class="themeData.theme_name === 'minimal' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-gray-400 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-black"></div>
<div class="w-4 h-4 rounded bg-gray-600"></div>
<div class="w-4 h-4 rounded bg-gray-400"></div>
</div>
</div>
<p class="text-gray-700 dark:text-gray-300">Minimal</p>
</button>
<!-- Custom CSS Section -->
<div class="mb-6">
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Advanced</h4>
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Custom CSS</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
Advanced: Add custom CSS rules (use with caution)
</p>
<textarea x-model="themeData.custom_css"
rows="6"
placeholder=".my-custom-class { color: red; }"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-textarea"></textarea>
</label>
</div>
<!-- Vibrant Preset -->
<button @click="applyPreset('vibrant')"
:disabled="saving"
:class="themeData.theme_name === 'vibrant' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-orange-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-orange-500"></div>
<div class="w-4 h-4 rounded bg-red-500"></div>
<div class="w-4 h-4 rounded bg-purple-600"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Vibrant</p>
</button>
<!-- Action Buttons -->
<div class="flex justify-between items-center">
<button @click="resetToDefault()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium leading-5 text-red-700 transition-colors duration-150 bg-white border border-red-300 rounded-lg hover:bg-red-50 focus:outline-none disabled:opacity-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-600">
Reset to Default
</button>
<button @click="saveTheme()"
: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 disabled:opacity-50">
<span x-show="!saving">Save Theme</span>
<span x-show="saving" class="flex items-center">
<span x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
Saving...
</span>
</button>
</div>
<!-- Elegant Preset -->
<button @click="applyPreset('elegant')"
:disabled="saving"
:class="themeData.theme_name === 'elegant' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-gray-400 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-gray-500"></div>
<div class="w-4 h-4 rounded bg-gray-700"></div>
<div class="w-4 h-4 rounded bg-amber-600"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Elegant</p>
</button>
<!-- Nature Preset -->
<button @click="applyPreset('nature')"
:disabled="saving"
:class="themeData.theme_name === 'nature' ? 'ring-2 ring-purple-500' : ''"
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-green-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
<div class="flex items-center justify-center space-x-2 mb-2">
<div class="w-4 h-4 rounded bg-green-600"></div>
<div class="w-4 h-4 rounded bg-emerald-500"></div>
<div class="w-4 h-4 rounded bg-amber-500"></div>
</div>
<p class="text-gray-700 dark:text-gray-300">Nature</p>
</button>
</div>
</div>
<!-- Preview Panel (1 column) -->
<div class="md:col-span-1">
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 sticky top-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Preview
</h3>
<!-- Colors Section -->
<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">
<span x-html="$icon('color-swatch', 'inline w-5 h-5 mr-2')"></span>
Colors
</h3>
<div class="grid gap-4 md:grid-cols-2">
<!-- Primary Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Primary Color</span>
<p class="text-xs text-gray-500 mb-2">Main brand color for buttons and links</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.primary"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.primary"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Theme Preview -->
<div class="space-y-4">
<!-- Colors Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">COLORS</p>
<div class="grid grid-cols-3 gap-2">
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
:style="`background-color: ${themeData.colors.primary}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Primary</p>
</div>
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
:style="`background-color: ${themeData.colors.secondary}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Secondary</p>
</div>
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
:style="`background-color: ${themeData.colors.accent}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Accent</p>
</div>
<!-- Secondary Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Secondary Color</span>
<p class="text-xs text-gray-500 mb-2">Supporting color for accents</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.secondary"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.secondary"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Accent Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Accent Color</span>
<p class="text-xs text-gray-500 mb-2">Call-to-action and highlights</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.accent"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.accent"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Background Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Background Color</span>
<p class="text-xs text-gray-500 mb-2">Page background</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.background"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.background"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Text Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Text Color</span>
<p class="text-xs text-gray-500 mb-2">Primary text color</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.text"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.text"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
<!-- Border Color -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Border Color</span>
<p class="text-xs text-gray-500 mb-2">Borders and dividers</p>
<div class="flex items-center mt-1 space-x-2">
<input type="color"
x-model="themeData.colors.border"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
<input type="text"
x-model="themeData.colors.border"
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
</div>
</label>
</div>
</div>
<!-- Typography Section -->
<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">
<span x-html="$icon('document-text', 'inline w-5 h-5 mr-2')"></span>
Typography
</h3>
<div class="grid gap-4 md:grid-cols-2">
<!-- Heading Font -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Heading Font</span>
<p class="text-xs text-gray-500 mb-2">For titles and headings</p>
<select x-model="themeData.fonts.heading"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="Inter, sans-serif">Inter (Modern)</option>
<option value="Roboto, sans-serif">Roboto (Clean)</option>
<option value="Poppins, sans-serif">Poppins (Friendly)</option>
<option value="Playfair Display, serif">Playfair Display (Elegant)</option>
<option value="Merriweather, serif">Merriweather (Classic)</option>
<option value="Georgia, serif">Georgia (Traditional)</option>
<option value="Helvetica, sans-serif">Helvetica (Minimal)</option>
<option value="Montserrat, sans-serif">Montserrat (Bold)</option>
</select>
</label>
<!-- Body Font -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Body Font</span>
<p class="text-xs text-gray-500 mb-2">For body text and content</p>
<select x-model="themeData.fonts.body"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="Inter, sans-serif">Inter (Modern)</option>
<option value="Roboto, sans-serif">Roboto (Clean)</option>
<option value="Open Sans, sans-serif">Open Sans (Readable)</option>
<option value="Lato, sans-serif">Lato (Friendly)</option>
<option value="Arial, sans-serif">Arial (Universal)</option>
<option value="Georgia, serif">Georgia (Traditional)</option>
<option value="Helvetica, sans-serif">Helvetica (Minimal)</option>
</select>
</label>
</div>
</div>
<!-- Layout Section -->
<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">
<span x-html="$icon('view-grid', 'inline w-5 h-5 mr-2')"></span>
Layout
</h3>
<div class="grid gap-4 md:grid-cols-3">
<!-- Product Layout Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Product Layout</span>
<select x-model="themeData.layout.style"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="grid">Grid</option>
<option value="list">List</option>
<option value="masonry">Masonry</option>
</select>
</label>
<!-- Header Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Header Style</span>
<select x-model="themeData.layout.header"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="fixed">Fixed</option>
<option value="static">Static</option>
<option value="transparent">Transparent</option>
</select>
</label>
<!-- Product Card Style -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400 font-medium">Product Card</span>
<select x-model="themeData.layout.product_card"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
<option value="modern">Modern</option>
<option value="classic">Classic</option>
<option value="minimal">Minimal</option>
</select>
</label>
</div>
</div>
<!-- Custom CSS Section -->
<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">
<span x-html="$icon('code', 'inline w-5 h-5 mr-2')"></span>
Advanced: Custom CSS
</h3>
<label class="block text-sm">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
Add custom CSS rules for advanced styling (use with caution)
</p>
<textarea x-model="themeData.custom_css"
rows="6"
placeholder=".my-custom-class {
color: red;
font-weight: bold;
}"
class="block w-full mt-1 text-sm font-mono dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-textarea rounded"></textarea>
</label>
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center">
<button @click="resetToDefault()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium leading-5 text-red-700 transition-colors duration-150 bg-white border border-red-300 rounded-lg hover:bg-red-50 focus:outline-none disabled:opacity-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-600">
<span x-html="$icon('refresh', 'inline w-4 h-4 mr-2')"></span>
Reset to Default
</button>
<button @click="saveTheme()"
:disabled="saving"
class="flex items-center px-6 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 disabled:opacity-50">
<span x-show="!saving">
<span x-html="$icon('save', 'inline w-4 h-4 mr-2')"></span>
Save Theme
</span>
<span x-show="saving" class="flex items-center">
<span x-html="$icon('spinner', 'inline w-4 h-4 mr-2 animate-spin')"></span>
Saving...
</span>
</button>
</div>
</div>
<!-- Preview Panel (1 column) -->
<div class="md:col-span-1">
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 sticky top-6">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('eye', 'inline w-5 h-5 mr-2')"></span>
Preview
</h3>
<!-- Theme Preview -->
<div class="space-y-4">
<!-- Current Theme Name -->
<div class="p-3 bg-purple-50 dark:bg-purple-900 dark:bg-opacity-20 rounded-lg">
<p class="text-xs font-semibold text-purple-800 dark:text-purple-200 mb-1">ACTIVE THEME</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="themeData.theme_name"></p>
</div>
<!-- Colors Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">COLORS</p>
<div class="grid grid-cols-3 gap-2">
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
:style="`background-color: ${themeData.colors.primary}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Primary</p>
</div>
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
:style="`background-color: ${themeData.colors.secondary}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Secondary</p>
</div>
<div class="text-center">
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
:style="`background-color: ${themeData.colors.accent}`"></div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Accent</p>
</div>
</div>
</div>
<!-- Typography Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">TYPOGRAPHY</p>
<div class="space-y-2">
<p class="text-lg" :style="`font-family: ${themeData.fonts.heading}`">
Heading Font
</p>
<p class="text-sm" :style="`font-family: ${themeData.fonts.body}`">
Body text font example
</p>
</div>
<!-- Typography Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">TYPOGRAPHY</p>
<div class="space-y-2 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-lg font-bold" :style="`font-family: ${themeData.fonts.heading}`">
Heading Font
</p>
<p class="text-sm" :style="`font-family: ${themeData.fonts.body}`">
This is body text font example. It will be used for paragraphs and descriptions.
</p>
</div>
</div>
<!-- Button Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">BUTTONS</p>
<button class="px-4 py-2 text-sm font-medium text-white rounded-lg w-full"
:style="`background-color: ${themeData.colors.primary}`">
Primary Button
</button>
</div>
<!-- Button Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">BUTTONS</p>
<button class="px-4 py-2 text-sm font-medium text-white rounded-lg w-full shadow-sm"
:style="`background-color: ${themeData.colors.primary}`">
Primary Button
</button>
</div>
<!-- Layout Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">LAYOUT</p>
<div class="text-xs space-y-1">
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Product Layout:</span>
<span class="capitalize" x-text="themeData.layout.style"></span>
</p>
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Header:</span>
<span class="capitalize" x-text="themeData.layout.header"></span>
</p>
</div>
<!-- Layout Preview -->
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">LAYOUT</p>
<div class="text-xs space-y-1 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Product Layout:</span>
<span class="capitalize" x-text="themeData.layout.style"></span>
</p>
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Header:</span>
<span class="capitalize" x-text="themeData.layout.header"></span>
</p>
<p class="text-gray-700 dark:text-gray-300">
<span class="font-semibold">Product Card:</span>
<span class="capitalize" x-text="themeData.layout.product_card"></span>
</p>
</div>
</div>
<!-- Preview Link -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<a :href="`http://${vendor?.subdomain}.localhost:8000`"
target="_blank"
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-300 rounded-lg hover:bg-purple-100 dark:bg-purple-900 dark:bg-opacity-20 dark:text-purple-300 dark:border-purple-700">
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
View Live Shop
</a>
</div>
<!-- Preview Link -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<a :href="`http://${vendor?.subdomain}.localhost:8000`"
target="_blank"
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-300 rounded-lg hover:bg-purple-100 dark:bg-purple-900 dark:bg-opacity-20 dark:text-purple-300 dark:border-purple-700 transition-colors">
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
View Live Shop
</a>
</div>
</div>
</div>