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

@@ -29,6 +29,18 @@ try:
except ImportError as e:
print(f" ✗ Vendor models failed: {e}")
try:
from models.database.vendor_domain import VendorDomain
print(" ✓ VendorDomain model imported")
except ImportError as e:
print(f" ✗ VendorDomain model failed: {e}")
try:
from models.database.vendor_theme import VendorTheme
print(" ✓ VendorTheme model imported")
except ImportError as e:
print(f" ✗ VendorTheme model failed: {e}")
# Product models
try:
from models.database.marketplace_product import MarketplaceProduct
@@ -56,14 +68,14 @@ try:
except ImportError as e:
print(f" ✗ MarketplaceImportJob model failed: {e}")
# Customer models (MISSING IN YOUR FILE)
# Customer models
try:
from models.database.customer import Customer, CustomerAddress
print(" ✓ Customer models imported (Customer, CustomerAddress)")
except ImportError as e:
print(f" ✗ Customer models failed: {e}")
# Order models (MISSING IN YOUR FILE)
# Order models
try:
from models.database.order import Order, OrderItem
print(" ✓ Order models imported (Order, OrderItem)")

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>

View File

@@ -0,0 +1,458 @@
# VENDOR THEME EDITOR - COMPLETE IMPLEMENTATION GUIDE
Following Your Frontend Architecture
## 📋 Overview
This implementation follows your **exact Alpine.js architecture pattern** with:
- ✅ Proper logging setup
-`...data()` inheritance
- ✅ Initialization guard pattern
- ✅ Lowercase `apiClient` usage
- ✅ Page-specific logger (`themeLog`)
-`currentPage` identifier
- ✅ Performance tracking
- ✅ Proper error handling
---
## 📦 Files to Install
### 1. JavaScript Component (Alpine.js)
**File:** `vendor-theme-alpine.js`
**Install to:** `static/admin/js/vendor-theme.js`
```bash
cp vendor-theme-alpine.js static/admin/js/vendor-theme.js
```
**Key Features:**
- ✅ Follows `dashboard.js` pattern exactly
- ✅ Uses `adminVendorTheme()` function name
- ✅ Inherits base with `...data()`
- ✅ Sets `currentPage: 'vendor-theme'`
- ✅ Has initialization guard
- ✅ Uses lowercase `apiClient`
- ✅ Has `themeLog` logger
- ✅ Performance tracking with `Date.now()`
---
### 2. API Endpoints (Backend)
**File:** `vendor_themes_api.py`
**Install to:** `app/api/v1/admin/vendor_themes.py`
```bash
cp vendor_themes_api.py app/api/v1/admin/vendor_themes.py
```
**Endpoints:**
```
GET /api/v1/admin/vendor-themes/presets
GET /api/v1/admin/vendor-themes/{vendor_code}
PUT /api/v1/admin/vendor-themes/{vendor_code}
POST /api/v1/admin/vendor-themes/{vendor_code}/preset/{preset_name}
DELETE /api/v1/admin/vendor-themes/{vendor_code}
```
---
### 3. Pydantic Schemas
**File:** `vendor_theme_schemas.py`
**Install to:** `models/schema/vendor_theme.py`
```bash
cp vendor_theme_schemas.py models/schema/vendor_theme.py
```
---
### 4. HTML Template
**File:** `vendor-theme.html`
**Install to:** `app/templates/admin/vendor-theme.html`
```bash
cp vendor-theme.html app/templates/admin/vendor-theme.html
```
**Key Features:**
- ✅ Extends `admin/base.html`
- ✅ Uses `{% block alpine_data %}adminVendorTheme(){% endblock %}`
- ✅ Loads script in `{% block extra_scripts %}`
- ✅ 7 preset buttons
- ✅ 6 color pickers
- ✅ Font and layout selectors
- ✅ Live preview panel
---
### 5. Frontend Router Update
**File:** `pages-updated.py`
**Install to:** `app/api/v1/admin/pages.py`
```bash
cp pages-updated.py app/api/v1/admin/pages.py
```
**Change:**
- Added route: `GET /vendors/{vendor_code}/theme`
- Returns: `admin/vendor-theme.html`
---
### 6. API Router Registration
**File:** `__init__-updated.py`
**Install to:** `app/api/v1/admin/__init__.py`
```bash
cp __init__-updated.py app/api/v1/admin/__init__.py
```
**Changes:**
```python
# Added import
from . import vendor_themes
# Added router registration
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
```
---
### 7. Theme Presets (if not already installed)
**File:** `theme_presets.py`
**Install to:** `app/core/theme_presets.py`
```bash
cp theme_presets.py app/core/theme_presets.py
```
---
## 🔧 Installation Steps
### Step 1: Copy All Files
```bash
# 1. JavaScript (Frontend)
cp vendor-theme-alpine.js static/admin/js/vendor-theme.js
# 2. API Endpoints (Backend)
cp vendor_themes_api.py app/api/v1/admin/vendor_themes.py
# 3. Pydantic Schemas
cp vendor_theme_schemas.py models/schema/vendor_theme.py
# 4. HTML Template
cp vendor-theme.html app/templates/admin/vendor-theme.html
# 5. Theme Presets (if not done)
cp theme_presets.py app/core/theme_presets.py
# 6. Update Frontend Router
cp pages-updated.py app/api/v1/admin/pages.py
# 7. Update API Router
cp __init__-updated.py app/api/v1/admin/__init__.py
```
---
### Step 2: Verify Database
Make sure the `vendor_themes` table exists:
```bash
# Check migrations
alembic history
# Run migration if needed
alembic upgrade head
```
---
### Step 3: Restart Server
```bash
# Stop server
pkill -f uvicorn
# Start server
python -m uvicorn app.main:app --reload
```
---
## 🧪 Testing
### 1. Check JavaScript Loading
Open browser console and look for:
```
[THEME INFO] Vendor theme editor module loaded
```
---
### 2. Test Page Load
Navigate to:
```
http://localhost:8000/admin/vendors/VENDOR001/theme
```
Expected console output:
```
[THEME INFO] === VENDOR THEME EDITOR INITIALIZING ===
[THEME INFO] Vendor code: VENDOR001
[THEME INFO] Loading vendor data...
[THEME INFO] Vendor loaded in 45ms: Vendor Name
[THEME INFO] Loading theme...
[THEME INFO] Theme loaded in 23ms: default
[THEME INFO] Loading presets...
[THEME INFO] 7 presets loaded in 12ms
[THEME INFO] === THEME EDITOR INITIALIZATION COMPLETE (80ms) ===
```
---
### 3. Test Preset Application
Click "Modern" preset button.
Expected console output:
```
[THEME INFO] Applying preset: modern
[THEME INFO] Preset applied in 56ms
```
Expected UI: Colors and fonts update instantly
---
### 4. Test Color Changes
1. Click primary color picker
2. Choose a new color
3. Preview should update immediately
---
### 5. Test Save
1. Click "Save Theme" button
2. Expected toast: "Theme saved successfully"
3. Expected console:
```
[THEME INFO] Saving theme: {theme_name: 'modern', ...}
[THEME INFO] Theme saved in 34ms
```
---
### 6. Test API Endpoints
```bash
# Get presets
curl http://localhost:8000/api/v1/admin/vendor-themes/presets \
-H "Authorization: Bearer YOUR_TOKEN"
# Get vendor theme
curl http://localhost:8000/api/v1/admin/vendor-themes/VENDOR001 \
-H "Authorization: Bearer YOUR_TOKEN"
# Apply preset
curl -X POST \
http://localhost:8000/api/v1/admin/vendor-themes/VENDOR001/preset/modern \
-H "Authorization: Bearer YOUR_TOKEN"
# Save theme
curl -X PUT \
http://localhost:8000/api/v1/admin/vendor-themes/VENDOR001 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"colors": {
"primary": "#ff0000"
}
}'
```
---
## 🎯 Architecture Compliance Checklist
### JavaScript File ✅
- [x] Logging setup (`themeLog`)
- [x] Function name: `adminVendorTheme()`
- [x] `...data()` at start
- [x] `currentPage: 'vendor-theme'`
- [x] Initialization guard (`window._vendorThemeInitialized`)
- [x] Uses lowercase `apiClient`
- [x] Uses page-specific logger (`themeLog`)
- [x] Performance tracking (`Date.now()`)
- [x] Module loaded log at end
---
### HTML Template ✅
- [x] Extends `admin/base.html`
- [x] `alpine_data` block uses `adminVendorTheme()`
- [x] `x-show` for loading states
- [x] `x-text` for reactive data
- [x] Loads JS in `extra_scripts` block
---
### API Routes ✅
- [x] RESTful endpoint structure
- [x] Proper error handling
- [x] Admin authentication required
- [x] Pydantic validation
- [x] Logging
---
## 📊 Data Flow
```
1. User visits /admin/vendors/VENDOR001/theme
2. pages.py returns vendor-theme.html
3. Template loads vendor-theme.js
4. Alpine.js calls adminVendorTheme()
5. Component spreads ...data() (gets base UI state)
6. Component adds page-specific state
7. init() runs (with guard)
8. Loads vendor data via apiClient.get()
9. Loads theme data via apiClient.get()
10. Loads presets via apiClient.get()
11. UI updates reactively
12. User clicks preset button
13. applyPreset() sends apiClient.post()
14. API applies preset and saves to DB
15. Response updates themeData
16. UI updates (colors, fonts, preview)
```
---
## 🐛 Troubleshooting
### "adminVendorTheme is not defined"
**Problem:** JavaScript file not loaded or function name mismatch
**Fix:**
1. Check template: `{% block alpine_data %}adminVendorTheme(){% endblock %}`
2. Check JS file has: `function adminVendorTheme() {`
3. Check JS file is loaded in `extra_scripts` block
---
### "apiClient is not defined"
**Problem:** `api-client.js` not loaded before your script
**Fix:** Check `base.html` loads scripts in this order:
1. `log-config.js`
2. `icons.js`
3. `init-alpine.js`
4. `utils.js`
5. `api-client.js`
6. Alpine.js CDN
7. Your page script (vendor-theme.js)
---
### Init runs multiple times
**Problem:** Guard not working
**Expected:** You should see this warning in console:
```
⚠️ [THEME WARN] Theme editor already initialized, skipping...
```
**If not appearing:** Check guard is in `init()`:
```javascript
if (window._vendorThemeInitialized) {
themeLog.warn('Theme editor already initialized, skipping...');
return;
}
window._vendorThemeInitialized = true;
```
---
### No console logs
**Problem:** Log level too low
**Fix:** Set `THEME_LOG_LEVEL = 4` at top of vendor-theme.js for all logs
---
## ✅ Final Checklist
- [ ] All 7 files copied
- [ ] API router registered in `__init__.py`
- [ ] Frontend route added in `pages.py`
- [ ] Database has `vendor_themes` table
- [ ] Server restarted
- [ ] Can access `/admin/vendors/VENDOR001/theme`
- [ ] Console shows module loaded log
- [ ] Console shows initialization logs
- [ ] Presets load and work
- [ ] Color pickers work
- [ ] Save button works
- [ ] No console errors
---
## 🎉 Success Indicators
When everything is working:
1. **Console logs:**
```
[THEME INFO] Vendor theme editor module loaded
[THEME INFO] === VENDOR THEME EDITOR INITIALIZING ===
[THEME INFO] === THEME EDITOR INITIALIZATION COMPLETE (XXms) ===
```
2. **No errors** in browser console
3. **Live preview** updates when you change colors
4. **Preset buttons** change theme instantly
5. **Save button** shows success toast
6. **Changes persist** after page refresh
---
## 📚 Related Documentation
- Frontend Architecture: `FRONTEND_ARCHITECTURE_OVERVIEW.txt`
- Alpine.js Template: `FRONTEND_ALPINE_PAGE_TEMPLATE.md`
- Your working example: `static/admin/js/dashboard.js`
---
**Your theme editor now follows your exact frontend architecture! 🎨**

View File

@@ -0,0 +1,360 @@
# THEME PRESETS USAGE GUIDE
## What Changed in Your Presets
### ✅ What You Had Right
- Good preset structure with colors, fonts, layout
- Clean `apply_preset()` function
- Good preset names (modern, classic, minimal, vibrant)
### 🔧 What We Added
1. **Missing color fields:** `background`, `text`, `border`
2. **Missing layout field:** `product_card` style
3. **"default" preset:** Your platform's default theme
4. **Extra presets:** "elegant" and "nature" themes
5. **Helper functions:** `get_preset()`, `get_available_presets()`, `get_preset_preview()`
6. **Custom preset builder:** `create_custom_preset()`
---
## Usage Examples
### 1. Apply Preset to New Vendor
```python
from models.database.vendor_theme import VendorTheme
from app.core.theme_presets import apply_preset
from app.core.database import SessionLocal
# Create theme for vendor
db = SessionLocal()
vendor_id = 1
# Create and apply preset
theme = VendorTheme(vendor_id=vendor_id)
apply_preset(theme, "modern")
db.add(theme)
db.commit()
```
### 2. Change Vendor's Theme
```python
from models.database.vendor_theme import VendorTheme
from app.core.theme_presets import apply_preset
# Get existing theme
theme = db.query(VendorTheme).filter(
VendorTheme.vendor_id == vendor_id
).first()
if theme:
# Update to new preset
apply_preset(theme, "classic")
else:
# Create new theme
theme = VendorTheme(vendor_id=vendor_id)
apply_preset(theme, "classic")
db.add(theme)
db.commit()
```
### 3. Get Available Presets (For UI Dropdown)
```python
from app.core.theme_presets import get_available_presets, get_preset_preview
# Get list of preset names
presets = get_available_presets()
# Returns: ['default', 'modern', 'classic', 'minimal', 'vibrant', 'elegant', 'nature']
# Get preview info for UI
previews = []
for preset_name in presets:
preview = get_preset_preview(preset_name)
previews.append(preview)
# Returns list of dicts with:
# {
# "name": "modern",
# "description": "Contemporary tech-inspired design...",
# "primary_color": "#6366f1",
# "secondary_color": "#8b5cf6",
# ...
# }
```
### 4. API Endpoint to Apply Preset
```python
# In your API route
from fastapi import APIRouter, Depends
from app.core.theme_presets import apply_preset, get_available_presets
@router.put("/theme/preset")
def apply_theme_preset(
preset_name: str,
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db),
):
"""Apply a theme preset to vendor."""
# Validate preset name
if preset_name not in get_available_presets():
raise HTTPException(
status_code=400,
detail=f"Invalid preset. Available: {get_available_presets()}"
)
# Get or create vendor theme
theme = db.query(VendorTheme).filter(
VendorTheme.vendor_id == vendor.id
).first()
if not theme:
theme = VendorTheme(vendor_id=vendor.id)
db.add(theme)
# Apply preset
apply_preset(theme, preset_name)
db.commit()
db.refresh(theme)
return {
"message": f"Theme preset '{preset_name}' applied successfully",
"theme": theme.to_dict()
}
```
### 5. Get All Presets for Theme Selector
```python
@router.get("/theme/presets")
def get_theme_presets():
"""Get all available theme presets with previews."""
from app.core.theme_presets import get_available_presets, get_preset_preview
presets = []
for preset_name in get_available_presets():
preview = get_preset_preview(preset_name)
presets.append(preview)
return {"presets": presets}
# Returns:
# {
# "presets": [
# {
# "name": "default",
# "description": "Clean and professional...",
# "primary_color": "#6366f1",
# "secondary_color": "#8b5cf6",
# "accent_color": "#ec4899",
# "heading_font": "Inter, sans-serif",
# "body_font": "Inter, sans-serif",
# "layout_style": "grid"
# },
# ...
# ]
# }
```
### 6. Create Custom Theme (Not from Preset)
```python
from app.core.theme_presets import create_custom_preset
# User provides custom colors
custom_preset = create_custom_preset(
colors={
"primary": "#ff0000",
"secondary": "#00ff00",
"accent": "#0000ff",
"background": "#ffffff",
"text": "#000000",
"border": "#cccccc"
},
fonts={
"heading": "Arial, sans-serif",
"body": "Verdana, sans-serif"
},
layout={
"style": "grid",
"header": "fixed",
"product_card": "modern"
},
name="my_custom"
)
# Apply to vendor theme
theme = VendorTheme(vendor_id=vendor_id)
theme.theme_name = "custom"
theme.colors = custom_preset["colors"]
theme.font_family_heading = custom_preset["fonts"]["heading"]
theme.font_family_body = custom_preset["fonts"]["body"]
theme.layout_style = custom_preset["layout"]["style"]
theme.header_style = custom_preset["layout"]["header"]
theme.product_card_style = custom_preset["layout"]["product_card"]
theme.is_active = True
db.add(theme)
db.commit()
```
---
## Available Presets
| Preset | Description | Primary Color | Use Case |
|--------|-------------|---------------|----------|
| `default` | Clean & professional | Indigo (#6366f1) | General purpose |
| `modern` | Tech-inspired | Indigo (#6366f1) | Tech products |
| `classic` | Traditional | Dark Blue (#1e40af) | Established brands |
| `minimal` | Ultra-clean B&W | Black (#000000) | Minimalist brands |
| `vibrant` | Bold & energetic | Orange (#f59e0b) | Creative brands |
| `elegant` | Sophisticated | Gray (#6b7280) | Luxury products |
| `nature` | Eco-friendly | Green (#059669) | Organic/eco brands |
---
## Complete Preset Structure
Each preset includes:
```python
{
"colors": {
"primary": "#6366f1", # Main brand color
"secondary": "#8b5cf6", # Supporting color
"accent": "#ec4899", # Call-to-action color
"background": "#ffffff", # Page background
"text": "#1f2937", # Text color
"border": "#e5e7eb" # Border/divider color
},
"fonts": {
"heading": "Inter, sans-serif", # Headings (h1-h6)
"body": "Inter, sans-serif" # Body text
},
"layout": {
"style": "grid", # grid | list | masonry
"header": "fixed", # fixed | static | transparent
"product_card": "modern" # modern | classic | minimal
}
}
```
---
## Integration with Admin Panel
### Theme Editor UI Flow
1. **Preset Selector**
```javascript
// Fetch available presets
fetch('/api/v1/vendor/theme/presets')
.then(r => r.json())
.then(data => {
// Display preset cards with previews
data.presets.forEach(preset => {
showPresetCard(preset.name, preset.primary_color, preset.description)
})
})
```
2. **Apply Preset Button**
```javascript
function applyPreset(presetName) {
fetch('/api/v1/vendor/theme/preset', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({preset_name: presetName})
})
.then(() => {
alert('Theme updated!')
location.reload() // Refresh to show new theme
})
}
```
3. **Custom Color Picker** (After applying preset)
```javascript
// User can then customize colors
function updateColors(colors) {
fetch('/api/v1/vendor/theme/colors', {
method: 'PUT',
body: JSON.stringify({colors})
})
}
```
---
## Testing Presets
```python
# Test script
from app.core.theme_presets import apply_preset, get_available_presets
from models.database.vendor_theme import VendorTheme
def test_all_presets():
"""Test applying all presets"""
presets = get_available_presets()
for preset_name in presets:
theme = VendorTheme(vendor_id=999) # Test vendor
apply_preset(theme, preset_name)
assert theme.theme_name == preset_name
assert theme.colors is not None
assert theme.font_family_heading is not None
assert theme.is_active == True
print(f"✅ {preset_name} preset OK")
test_all_presets()
```
---
## CSS Variables Generation
Your middleware already handles this via `VendorTheme.to_dict()`, which includes:
```python
"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",
}
```
Use in templates:
```html
<style>
:root {
{% for key, value in theme.css_variables.items() %}
{{ key }}: {{ value }};
{% endfor %}
}
</style>
```
---
## Next Steps
1. ✅ Copy `theme_presets.py` to `app/core/theme_presets.py`
2. ✅ Create API endpoints for applying presets
3. ✅ Build theme selector UI in admin panel
4. ✅ Test all presets work correctly
5. ✅ Add custom color picker for fine-tuning
Perfect! Your presets are now complete and production-ready! 🎨

View File

@@ -0,0 +1,536 @@
# VENDOR THEME BACKEND - PROPER ARCHITECTURE IMPLEMENTATION
## Overview
This implementation follows your **complete backend architecture** with:
-**Separation of Concerns** - Service layer handles business logic
-**Exception Management** - Custom exceptions for all error cases
-**Proper Layering** - Database → Service → API → Frontend
-**Naming Conventions** - Follows your established patterns
-**No Business Logic in Endpoints** - All logic in service layer
---
## 📦 File Structure
```
app/
├── exceptions/
│ ├── __init__.py # ← UPDATE (add vendor_theme imports)
│ └── vendor_theme.py # ← NEW (custom exceptions)
├── services/
│ └── vendor_theme_service.py # ← NEW (business logic)
└── api/v1/admin/
├── __init__.py # ← UPDATE (register router)
├── pages.py # ← UPDATE (add theme route)
└── vendor_themes.py # ← NEW (endpoints only)
models/
└── schema/
└── vendor_theme.py # ← Already created (Pydantic)
static/admin/js/
└── vendor-theme.js # ← Already created (Alpine.js)
app/templates/admin/
└── vendor-theme.html # ← Already created (HTML)
```
---
## 🏗️ Architecture Layers
### Layer 1: Exceptions (Error Handling)
**File:** `app/exceptions/vendor_theme.py`
```python
# Custom exceptions for domain-specific errors
- VendorThemeNotFoundException
- ThemePresetNotFoundException
- InvalidThemeDataException
- ThemeValidationException
- InvalidColorFormatException
- InvalidFontFamilyException
- ThemeOperationException
```
**Key Points:**
- ✅ Extends base exceptions (`ResourceNotFoundException`, etc.)
- ✅ Provides specific error codes
- ✅ Includes detailed error information
- ✅ No HTTP status codes (that's endpoint layer)
---
### Layer 2: Service (Business Logic)
**File:** `app/services/vendor_theme_service.py`
**Responsibilities:**
- ✅ Get/Create/Update/Delete themes
- ✅ Apply presets
- ✅ Validate theme data
- ✅ Database operations
- ✅ Raise custom exceptions
**Does NOT:**
- ❌ Handle HTTP requests/responses
- ❌ Raise HTTPException
- ❌ Know about FastAPI
**Example Service Method:**
```python
def update_theme(
self,
db: Session,
vendor_code: str,
theme_data: VendorThemeUpdate
) -> VendorTheme:
"""Update theme - returns VendorTheme or raises exception."""
try:
vendor = self._get_vendor_by_code(db, vendor_code)
theme = # ... get or create theme
# Validate
self._validate_theme_data(theme_data)
# Update
self._apply_theme_updates(theme, theme_data)
db.commit()
return theme
except CustomException:
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
raise ThemeOperationException(...) # Wrap generic errors
```
---
### Layer 3: API Endpoints (HTTP Interface)
**File:** `app/api/v1/admin/vendor_themes.py`
**Responsibilities:**
- ✅ Handle HTTP requests
- ✅ Call service methods
- ✅ Convert exceptions to HTTP responses
- ✅ Return JSON responses
**Does NOT:**
- ❌ Contain business logic
- ❌ Validate data (service does this)
- ❌ Access database directly
**Example Endpoint:**
```python
@router.put("/{vendor_code}")
async def update_vendor_theme(
vendor_code: str,
theme_data: VendorThemeUpdate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
"""Endpoint - just calls service and handles errors."""
try:
# Call service (all logic there)
theme = vendor_theme_service.update_theme(db, vendor_code, theme_data)
# Return response
return theme.to_dict()
except VendorNotFoundException:
raise HTTPException(status_code=404, detail="...")
except ThemeValidationException as e:
raise HTTPException(status_code=422, detail=e.message)
except Exception as e:
logger.error(...)
raise HTTPException(status_code=500, detail="...")
```
---
## 📥 Installation
### Step 1: Install Exception Layer
```bash
# 1. Copy exception file
cp vendor_theme_exceptions.py app/exceptions/vendor_theme.py
# 2. Update exceptions __init__.py
cp exceptions__init__-updated.py app/exceptions/__init__.py
```
**Verify:**
```python
from app.exceptions import VendorThemeNotFoundException # Should work
```
---
### Step 2: Install Service Layer
```bash
# Copy service file
cp vendor_theme_service.py app/services/vendor_theme_service.py
```
**Verify:**
```python
from app.services.vendor_theme_service import vendor_theme_service # Should work
```
---
### Step 3: Install API Layer
```bash
# Copy API endpoints
cp vendor_themes_endpoints.py app/api/v1/admin/vendor_themes.py
```
**Verify:**
```python
from app.api.v1.admin import vendor_themes # Should work
```
---
### Step 4: Register Router
```bash
# Update API router
cp __init__-updated.py app/api/v1/admin/__init__.py
```
**Changes:**
```python
# Added:
from . import vendor_themes
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
```
---
### Step 5: Update Frontend Router
```bash
# Update pages router
cp pages-updated.py app/api/v1/admin/pages.py
```
**Added Route:**
```python
@router.get("/vendors/{vendor_code}/theme")
async def admin_vendor_theme_page(...):
return templates.TemplateResponse("admin/vendor-theme.html", ...)
```
---
### Step 6: Copy Frontend Files (Already Done)
```bash
# JavaScript
cp vendor-theme-alpine.js static/admin/js/vendor-theme.js
# HTML Template
cp vendor-theme.html app/templates/admin/vendor-theme.html
# Pydantic Schemas
cp vendor_theme_schemas.py models/schema/vendor_theme.py
# Theme Presets
cp theme_presets.py app/core/theme_presets.py
```
---
## 🔄 Data Flow
### Complete Request Flow
```
1. User clicks "Save Theme" button
2. JavaScript (vendor-theme.js)
- apiClient.put('/api/v1/admin/vendor-themes/VENDOR001', data)
3. API Endpoint (vendor_themes.py)
- Receives HTTP PUT request
- Validates admin authentication
- Calls service layer
4. Service Layer (vendor_theme_service.py)
- Validates theme data
- Gets vendor from database
- Creates/updates VendorTheme
- Commits transaction
- Returns VendorTheme object
5. API Endpoint
- Converts VendorTheme to dict
- Returns JSON response
6. JavaScript
- Receives response
- Shows success toast
- Updates UI
```
### Error Flow
```
1. Service detects invalid color
2. Service raises InvalidColorFormatException
3. API endpoint catches exception
4. API converts to HTTPException(422)
5. FastAPI returns JSON error
6. JavaScript catches error
7. Shows error toast to user
```
---
## ✅ Architecture Compliance
### Separation of Concerns ✅
| Layer | Responsibilities | ✅ |
|-------|-----------------|-----|
| **Exceptions** | Define error types | ✅ |
| **Service** | Business logic, validation, DB operations | ✅ |
| **API** | HTTP handling, auth, error conversion | ✅ |
| **Frontend** | User interaction, API calls | ✅ |
### What Goes Where
```python
# ❌ WRONG - Business logic in endpoint
@router.put("/{vendor_code}")
async def update_theme(...):
vendor = db.query(Vendor).filter(...).first() # ❌ Direct DB
if not vendor:
raise HTTPException(404) # ❌ Should be custom exception
theme.colors = theme_data.colors # ❌ Business logic
if not self._is_valid_color(theme.colors['primary']): # ❌ Validation
raise HTTPException(422)
db.commit() # ❌ Transaction management
return theme.to_dict()
# ✅ CORRECT - Clean separation
@router.put("/{vendor_code}")
async def update_theme(...):
try:
# Just call service
theme = vendor_theme_service.update_theme(db, vendor_code, theme_data)
return theme.to_dict()
except VendorNotFoundException:
raise HTTPException(404)
except ThemeValidationException as e:
raise HTTPException(422, detail=e.message)
```
---
## 🧪 Testing
### Unit Tests (Service Layer)
```python
# tests/unit/services/test_vendor_theme_service.py
def test_update_theme_validates_colors(service, db, vendor):
"""Test color validation."""
theme_data = VendorThemeUpdate(
colors={"primary": "not-a-color"} # Invalid
)
with pytest.raises(InvalidColorFormatException):
service.update_theme(db, vendor.vendor_code, theme_data)
def test_apply_preset_invalid_name(service, db, vendor):
"""Test invalid preset name."""
with pytest.raises(ThemePresetNotFoundException) as exc_info:
service.apply_theme_preset(db, vendor.vendor_code, "invalid")
assert "invalid" in str(exc_info.value.message)
```
### Integration Tests (API Layer)
```python
# tests/integration/api/v1/admin/test_vendor_themes.py
def test_update_theme_endpoint(client, admin_headers, vendor):
"""Test theme update endpoint."""
response = client.put(
f"/api/v1/admin/vendor-themes/{vendor.vendor_code}",
json={"colors": {"primary": "#ff0000"}},
headers=admin_headers
)
assert response.status_code == 200
assert response.json()["colors"]["primary"] == "#ff0000"
def test_update_theme_invalid_color(client, admin_headers, vendor):
"""Test validation error response."""
response = client.put(
f"/api/v1/admin/vendor-themes/{vendor.vendor_code}",
json={"colors": {"primary": "invalid"}},
headers=admin_headers
)
assert response.status_code == 422
assert "invalid color" in response.json()["detail"].lower()
```
---
## 📊 Comparison: Before vs After
### Before (Original) ❌
```python
# vendor_themes_api.py (OLD)
@router.put("/{vendor_code}")
async def update_vendor_theme(vendor_code: str, theme_data: dict, ...):
# ❌ Direct database access
vendor = db.query(Vendor).filter(...).first()
# ❌ Business logic in endpoint
if not theme:
theme = VendorTheme(vendor_id=vendor.id)
db.add(theme)
# ❌ Data manipulation in endpoint
if "colors" in theme_data:
theme.colors = theme_data["colors"]
# ❌ Transaction management in endpoint
db.commit()
db.refresh(theme)
# ❌ Generic exception
except Exception as e:
raise HTTPException(500, detail="Failed")
```
### After (Refactored) ✅
```python
# vendor_themes.py (NEW - API Layer)
@router.put("/{vendor_code}")
async def update_vendor_theme(vendor_code: str, theme_data: VendorThemeUpdate, ...):
try:
# ✅ Just call service
theme = vendor_theme_service.update_theme(db, vendor_code, theme_data)
return theme.to_dict()
# ✅ Specific exception handling
except VendorNotFoundException:
raise HTTPException(404, detail="Vendor not found")
except ThemeValidationException as e:
raise HTTPException(422, detail=e.message)
# vendor_theme_service.py (NEW - Service Layer)
class VendorThemeService:
def update_theme(self, db, vendor_code, theme_data):
try:
# ✅ Business logic here
vendor = self._get_vendor_by_code(db, vendor_code)
theme = self._get_or_create_theme(db, vendor)
# ✅ Validation
self._validate_theme_data(theme_data)
# ✅ Data updates
self._apply_theme_updates(theme, theme_data)
# ✅ Transaction management
db.commit()
return theme
# ✅ Custom exceptions
except ValidationError:
raise ThemeValidationException(...)
```
---
## 📚 Files Reference
### Backend Files (7 files)
1. **vendor_theme_exceptions.py**`app/exceptions/vendor_theme.py`
- Custom exception classes
2. **exceptions__init__-updated.py**`app/exceptions/__init__.py`
- Updated with vendor_theme imports
3. **vendor_theme_service.py**`app/services/vendor_theme_service.py`
- Business logic service
4. **vendor_themes_endpoints.py**`app/api/v1/admin/vendor_themes.py`
- API endpoints (thin layer)
5. **__init__-updated.py**`app/api/v1/admin/__init__.py`
- Router registration
6. **pages-updated.py**`app/api/v1/admin/pages.py`
- Frontend route
7. **vendor_theme_schemas.py**`models/schema/vendor_theme.py`
- Pydantic models
### Frontend Files (3 files)
8. **vendor-theme-alpine.js**`static/admin/js/vendor-theme.js`
9. **vendor-theme.html**`app/templates/admin/vendor-theme.html`
10. **theme_presets.py**`app/core/theme_presets.py`
---
## ✅ Final Checklist
### Backend Architecture
- [ ] Exceptions in `app/exceptions/vendor_theme.py`
- [ ] Service in `app/services/vendor_theme_service.py`
- [ ] Endpoints in `app/api/v1/admin/vendor_themes.py`
- [ ] No business logic in endpoints
- [ ] No HTTPException in service
- [ ] Custom exceptions used throughout
### Frontend Architecture
- [ ] Alpine.js component with `...data()`
- [ ] Uses lowercase `apiClient`
- [ ] Has initialization guard
- [ ] Follows dashboard.js pattern
### Integration
- [ ] Router registered in `__init__.py`
- [ ] Frontend route in `pages.py`
- [ ] All exceptions imported
- [ ] Service imported in endpoints
---
**Your theme editor now follows proper backend architecture with complete separation of concerns!** 🎯

View File

@@ -224,11 +224,11 @@ Simplifying to minimal working version:
- Extends base.html
- Alpine.js adminDashboard() component
4. **`app/templates/partials/header.html`** ✅
4. **`app/templates/admin/partials/header.html`** ✅
- Top navigation bar
- Updated logout link to /admin/login
5. **`app/templates/partials/sidebar.html`** ✅
5. **`app/templates/admin/partials/sidebar.html`** ✅
- Side navigation menu
- Updated all links to /admin/* paths

View File

@@ -1,11 +1,9 @@
# models/database/vendor.py - ENHANCED VERSION
# models/database/vendor.py
"""
Enhanced Vendor model with theme support.
Vendor model with theme support.
Changes from your current version:
1. Keep existing theme_config JSON field
2. Add optional VendorTheme relationship for advanced themes
3. Add helper methods for theme access
A vendor has ONE active theme stored in the vendor_themes table.
Theme presets available: default, modern, classic, minimal, vibrant
"""
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
from sqlalchemy.orm import relationship
@@ -27,10 +25,6 @@ class Vendor(Base, TimestampMixin):
description = Column(Text)
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Simple theme config (JSON)
# This stores basic theme settings like colors, fonts
theme_config = Column(JSON, default=dict)
# Contact information
contact_email = Column(String)
contact_phone = Column(String)
@@ -49,13 +43,16 @@ class Vendor(Base, TimestampMixin):
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
# ========================================================================
# Relationships
# ========================================================================
owner = relationship("User", back_populates="owned_vendors")
vendor_users = relationship("VendorUser", back_populates="vendor")
products = relationship("Product", back_populates="vendor")
customers = relationship("Customer", back_populates="vendor")
orders = relationship("Order", back_populates="vendor")
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="vendor")
domains = relationship(
"VendorDomain",
back_populates="vendor",
@@ -63,16 +60,8 @@ class Vendor(Base, TimestampMixin):
order_by="VendorDomain.is_primary.desc()"
)
theme = relationship(
"VendorTheme",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan"
)
# Optional advanced theme (for premium vendors)
# This is optional - vendors can use theme_config OR VendorTheme
advanced_theme = relationship(
# Single theme relationship (ONE vendor = ONE theme)
vendor_theme = relationship(
"VendorTheme",
back_populates="vendor",
uselist=False,
@@ -86,81 +75,22 @@ class Vendor(Base, TimestampMixin):
# Theme Helper Methods
# ========================================================================
@property
def active_theme(self):
"""Get vendor's active theme or return default"""
if self.theme and self.theme.is_active:
return self.theme
return None
@property
def theme(self):
def get_effective_theme(self) -> dict:
"""
Get theme configuration for this vendor.
Get active theme for this vendor.
Priority:
1. Advanced theme (VendorTheme) if configured
2. theme_config JSON field
3. Default theme
Returns theme from vendor_themes table, or default theme if not set.
Returns dict with theme configuration.
Returns:
dict: Theme configuration with colors, fonts, layout, etc.
"""
# Priority 1: Advanced theme
if self.advanced_theme and self.advanced_theme.is_active:
return self.advanced_theme.to_dict()
# Check vendor_themes table
if self.vendor_theme and self.vendor_theme.is_active:
return self.vendor_theme.to_dict()
# Priority 2: Basic theme_config
if self.theme_config:
return self._normalize_theme_config(self.theme_config)
# Priority 3: Default theme
# Return default theme
return self._get_default_theme()
def _normalize_theme_config(self, config: dict) -> dict:
"""
Normalize theme_config JSON to standard format.
Ensures backward compatibility with existing theme_config.
"""
return {
"theme_name": config.get("theme_name", "basic"),
"colors": config.get("colors", {
"primary": "#6366f1",
"secondary": "#8b5cf6",
"accent": "#ec4899"
}),
"fonts": config.get("fonts", {
"heading": "Inter, sans-serif",
"body": "Inter, sans-serif"
}),
"branding": config.get("branding", {
"logo": None,
"logo_dark": None,
"favicon": None
}),
"layout": config.get("layout", {
"style": "grid",
"header": "fixed"
}),
"custom_css": config.get("custom_css", None),
"css_variables": self._generate_css_variables(config)
}
def _generate_css_variables(self, config: dict) -> dict:
"""Generate CSS custom properties from theme config"""
colors = config.get("colors", {})
fonts = config.get("fonts", {})
return {
"--color-primary": colors.get("primary", "#6366f1"),
"--color-secondary": colors.get("secondary", "#8b5cf6"),
"--color-accent": colors.get("accent", "#ec4899"),
"--color-background": colors.get("background", "#ffffff"),
"--color-text": colors.get("text", "#1f2937"),
"--color-border": colors.get("border", "#e5e7eb"),
"--font-heading": fonts.get("heading", "Inter, sans-serif"),
"--font-body": fonts.get("body", "Inter, sans-serif"),
}
def _get_default_theme(self) -> dict:
"""Default theme configuration"""
return {
@@ -180,12 +110,15 @@ class Vendor(Base, TimestampMixin):
"branding": {
"logo": None,
"logo_dark": None,
"favicon": None
"favicon": None,
"banner": None
},
"layout": {
"style": "grid",
"header": "fixed"
"header": "fixed",
"product_card": "modern"
},
"social_links": {},
"custom_css": None,
"css_variables": {
"--color-primary": "#6366f1",
@@ -199,42 +132,15 @@ class Vendor(Base, TimestampMixin):
}
}
@property
def primary_color(self):
"""Get primary color from theme"""
return self.theme.get("colors", {}).get("primary", "#6366f1")
def get_primary_color(self) -> str:
"""Get primary color from active theme"""
theme = self.get_effective_theme()
return theme.get("colors", {}).get("primary", "#6366f1")
@property
def logo_url(self):
"""Get logo URL from theme"""
return self.theme.get("branding", {}).get("logo")
def update_theme(self, theme_data: dict):
"""
Update vendor theme configuration.
Args:
theme_data: Dict with theme settings
{colors: {...}, fonts: {...}, etc}
"""
if not self.theme_config:
self.theme_config = {}
# Update theme_config JSON
if "colors" in theme_data:
self.theme_config["colors"] = theme_data["colors"]
if "fonts" in theme_data:
self.theme_config["fonts"] = theme_data["fonts"]
if "branding" in theme_data:
self.theme_config["branding"] = theme_data["branding"]
if "layout" in theme_data:
self.theme_config["layout"] = theme_data["layout"]
if "custom_css" in theme_data:
self.theme_config["custom_css"] = theme_data["custom_css"]
def get_logo_url(self) -> str:
"""Get logo URL from active theme"""
theme = self.get_effective_theme()
return theme.get("branding", {}).get("logo")
# ========================================================================
# Domain Helper Methods
@@ -257,6 +163,7 @@ class Vendor(Base, TimestampMixin):
domains.append(domain.domain)
return domains
# Keep your existing VendorUser and Role models unchanged
class VendorUser(Base, TimestampMixin):
__tablename__ = "vendor_users"

View File

@@ -3,30 +3,37 @@
Vendor Theme Configuration Model
Allows each vendor to customize their shop's appearance
"""
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, String, Boolean, Text, JSON, DateTime, ForeignKey
from sqlalchemy import Column, Integer, String, Boolean, Text, JSON, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorTheme(Base, TimestampMixin):
"""
Stores theme configuration for each vendor's shop.
Each vendor can have:
Each vendor can have ONE active theme:
- Custom colors (primary, secondary, accent)
- Custom fonts
- Custom logo and favicon
- Custom CSS overrides
- Layout preferences
Theme presets available: default, modern, classic, minimal, vibrant
"""
__tablename__ = "vendor_themes"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=False, unique=True)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
nullable=False,
unique=True # ONE vendor = ONE theme
)
# Basic Theme Settings
theme_name = Column(String(100), default="default") # e.g., "modern", "classic", "minimal"
theme_name = Column(String(100), default="default") # default, modern, classic, minimal, vibrant
is_active = Column(Boolean, default=True)
# Color Scheme (JSON for flexibility)
@@ -64,8 +71,8 @@ class VendorTheme(Base, TimestampMixin):
meta_title_template = Column(String(200), nullable=True) # e.g., "{product_name} - {shop_name}"
meta_description = Column(Text, nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="theme")
# Relationships - FIXED: back_populates must match the relationship name in Vendor model
vendor = relationship("Vendor", back_populates="vendor_theme")
def __repr__(self):
return f"<VendorTheme(vendor_id={self.vendor_id}, theme_name='{self.theme_name}')>"

View File

@@ -66,9 +66,6 @@ class VendorCreate(BaseModel):
letzshop_csv_url_en: Optional[str] = Field(None, description="English CSV URL")
letzshop_csv_url_de: Optional[str] = Field(None, description="German CSV URL")
# Theme Configuration
theme_config: Optional[Dict] = Field(default_factory=dict, description="Theme settings")
@field_validator("owner_email", "contact_email")
@classmethod
def validate_emails(cls, v):
@@ -122,9 +119,6 @@ class VendorUpdate(BaseModel):
letzshop_csv_url_en: Optional[str] = None
letzshop_csv_url_de: Optional[str] = None
# Theme Configuration
theme_config: Optional[Dict] = None
# Status (Admin only)
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
@@ -171,9 +165,6 @@ class VendorResponse(BaseModel):
letzshop_csv_url_en: Optional[str]
letzshop_csv_url_de: Optional[str]
# Theme Configuration
theme_config: Dict
# Status Flags
is_active: bool
is_verified: bool

View File

@@ -0,0 +1,84 @@
# models/schema/vendor_theme.py
"""
Pydantic schemas for vendor theme operations.
"""
from typing import Dict, Optional, List
from pydantic import BaseModel, Field
class VendorThemeColors(BaseModel):
"""Color scheme for vendor theme."""
primary: Optional[str] = Field(None, description="Primary brand color")
secondary: Optional[str] = Field(None, description="Secondary color")
accent: Optional[str] = Field(None, description="Accent/CTA color")
background: Optional[str] = Field(None, description="Background color")
text: Optional[str] = Field(None, description="Text color")
border: Optional[str] = Field(None, description="Border color")
class VendorThemeFonts(BaseModel):
"""Typography settings for vendor theme."""
heading: Optional[str] = Field(None, description="Font for headings")
body: Optional[str] = Field(None, description="Font for body text")
class VendorThemeBranding(BaseModel):
"""Branding assets for vendor theme."""
logo: Optional[str] = Field(None, description="Logo URL")
logo_dark: Optional[str] = Field(None, description="Dark mode logo URL")
favicon: Optional[str] = Field(None, description="Favicon URL")
banner: Optional[str] = Field(None, description="Banner image URL")
class VendorThemeLayout(BaseModel):
"""Layout settings for vendor theme."""
style: Optional[str] = Field(None, description="Product layout style (grid, list, masonry)")
header: Optional[str] = Field(None, description="Header style (fixed, static, transparent)")
product_card: Optional[str] = Field(None, description="Product card style (modern, classic, minimal)")
class VendorThemeUpdate(BaseModel):
"""Schema for updating vendor theme (partial updates allowed)."""
theme_name: Optional[str] = Field(None, description="Theme preset name")
colors: Optional[Dict[str, str]] = Field(None, description="Color scheme")
fonts: Optional[Dict[str, str]] = Field(None, description="Font settings")
branding: Optional[Dict[str, Optional[str]]] = Field(None, description="Branding assets")
layout: Optional[Dict[str, str]] = Field(None, description="Layout settings")
custom_css: Optional[str] = Field(None, description="Custom CSS rules")
social_links: Optional[Dict[str, str]] = Field(None, description="Social media links")
class VendorThemeResponse(BaseModel):
"""Schema for vendor theme response."""
theme_name: str = Field(..., description="Theme name")
colors: Dict[str, str] = Field(..., description="Color scheme")
fonts: Dict[str, str] = Field(..., description="Font settings")
branding: Dict[str, Optional[str]] = Field(..., description="Branding assets")
layout: Dict[str, str] = Field(..., description="Layout settings")
social_links: Optional[Dict[str, str]] = Field(default_factory=dict, description="Social links")
custom_css: Optional[str] = Field(None, description="Custom CSS")
css_variables: Optional[Dict[str, str]] = Field(None, description="CSS custom properties")
class ThemePresetPreview(BaseModel):
"""Preview information for a theme preset."""
name: str = Field(..., description="Preset name")
description: str = Field(..., description="Preset description")
primary_color: str = Field(..., description="Primary color")
secondary_color: str = Field(..., description="Secondary color")
accent_color: str = Field(..., description="Accent color")
heading_font: str = Field(..., description="Heading font")
body_font: str = Field(..., description="Body font")
layout_style: str = Field(..., description="Layout style")
class ThemePresetResponse(BaseModel):
"""Response after applying a preset."""
message: str = Field(..., description="Success message")
theme: VendorThemeResponse = Field(..., description="Applied theme")
class ThemePresetListResponse(BaseModel):
"""List of available theme presets."""
presets: List[ThemePresetPreview] = Field(..., description="Available presets")

View File

@@ -1,10 +1,14 @@
// static/admin/js/vendor-theme.js
/**
* Vendor Theme Management Component
* Follows the established Alpine.js pattern from FRONTEND_ALPINE_PAGE_TEMPLATE.md
* Vendor Theme Editor - Alpine.js Component
* Manages theme customization for vendor shops
*/
const THEME_LOG_LEVEL = 3;
// ============================================================================
// LOGGING CONFIGURATION
// ============================================================================
const THEME_LOG_LEVEL = 3; // 1=error, 2=warn, 3=info, 4=debug
const themeLog = {
error: (...args) => THEME_LOG_LEVEL >= 1 && console.error('❌ [THEME ERROR]', ...args),
@@ -13,71 +17,12 @@ const themeLog = {
debug: (...args) => THEME_LOG_LEVEL >= 4 && console.log('🔍 [THEME DEBUG]', ...args)
};
// Theme presets
const THEME_PRESETS = {
modern: {
colors: {
primary: "#6366f1",
secondary: "#8b5cf6",
accent: "#ec4899"
},
fonts: {
heading: "Inter, sans-serif",
body: "Inter, sans-serif"
},
layout: {
style: "grid",
header: "fixed"
}
},
classic: {
colors: {
primary: "#1e40af",
secondary: "#7c3aed",
accent: "#dc2626"
},
fonts: {
heading: "Georgia, serif",
body: "Arial, sans-serif"
},
layout: {
style: "list",
header: "static"
}
},
minimal: {
colors: {
primary: "#000000",
secondary: "#404040",
accent: "#666666"
},
fonts: {
heading: "Helvetica, sans-serif",
body: "Helvetica, sans-serif"
},
layout: {
style: "grid",
header: "transparent"
}
},
vibrant: {
colors: {
primary: "#f59e0b",
secondary: "#ef4444",
accent: "#8b5cf6"
},
fonts: {
heading: "Poppins, sans-serif",
body: "Open Sans, sans-serif"
},
layout: {
style: "masonry",
header: "fixed"
}
}
};
function vendorThemeData() {
// ============================================================================
// ALPINE.JS COMPONENT
// ============================================================================
function adminVendorTheme() {
return {
// ✅ CRITICAL: Inherit base layout functionality
...data(),
@@ -86,193 +31,279 @@ function vendorThemeData() {
currentPage: 'vendor-theme',
// Page state
loading: false,
saving: false,
vendorCode: null,
vendor: null,
vendorCode: window.location.pathname.split('/')[3], // Extract from /admin/vendors/{code}/theme
loading: true,
saving: false,
error: null,
// Theme data
// Theme data structure matching VendorTheme model
themeData: {
theme_name: 'default',
colors: {
primary: "#6366f1",
secondary: "#8b5cf6",
accent: "#ec4899"
primary: '#6366f1',
secondary: '#8b5cf6',
accent: '#ec4899',
background: '#ffffff',
text: '#1f2937',
border: '#e5e7eb'
},
fonts: {
heading: "Inter, sans-serif",
body: "Inter, sans-serif"
heading: 'Inter, sans-serif',
body: 'Inter, sans-serif'
},
layout: {
style: "grid",
header: "fixed"
style: 'grid',
header: 'fixed',
product_card: 'modern'
},
custom_css: ""
branding: {
logo: null,
logo_dark: null,
favicon: null,
banner: null
},
custom_css: ''
},
originalTheme: null, // For detecting changes
// Available presets
presets: [],
// ============================================================================
// INITIALIZATION
// ============================================================================
// ✅ CRITICAL: Proper initialization with guard
async init() {
themeLog.info('=== VENDOR THEME PAGE INITIALIZING ===');
themeLog.info('=== VENDOR THEME EDITOR INITIALIZING ===');
// Prevent multiple initializations
// ✅ CRITICAL: Prevent multiple initializations
if (window._vendorThemeInitialized) {
themeLog.warn('Page already initialized, skipping...');
themeLog.warn('Theme editor already initialized, skipping...');
return;
}
window._vendorThemeInitialized = true;
const startTime = Date.now();
// Get vendor code from URL
this.vendorCode = this.getVendorCodeFromURL();
themeLog.info('Vendor code:', this.vendorCode);
// Load data
await this.loadVendor();
await this.loadTheme();
await Promise.all([
this.loadVendorData(),
this.loadTheme(),
this.loadPresets()
]);
themeLog.info('=== VENDOR THEME PAGE INITIALIZATION COMPLETE ===');
const duration = Date.now() - startTime;
themeLog.info(`=== THEME EDITOR INITIALIZATION COMPLETE (${duration}ms) ===`);
},
// Load vendor info
async loadVendor() {
themeLog.info('Loading vendor:', this.vendorCode);
// ============================================================================
// URL HELPERS
// ============================================================================
try {
// ✅ CRITICAL: Use lowercase apiClient
const response = await apiClient.get(`/api/v1/admin/vendors/${this.vendorCode}`);
this.vendor = response;
themeLog.info('Vendor loaded:', this.vendor.name);
} catch (error) {
themeLog.error('Failed to load vendor:', error);
Utils.showToast('Failed to load vendor', 'error');
}
getVendorCodeFromURL() {
const pathParts = window.location.pathname.split('/');
const vendorIndex = pathParts.indexOf('vendors');
return pathParts[vendorIndex + 1];
},
// Load theme configuration
async loadTheme() {
themeLog.info('Loading theme...');
this.loading = true;
// ============================================================================
// DATA LOADING
// ============================================================================
async loadVendorData() {
themeLog.info('Loading vendor data...');
try {
const startTime = Date.now();
// Get vendor's theme config from vendor object
if (this.vendor && this.vendor.theme_config) {
this.themeData = {
colors: this.vendor.theme_config.colors || this.themeData.colors,
fonts: this.vendor.theme_config.fonts || this.themeData.fonts,
layout: this.vendor.theme_config.layout || this.themeData.layout,
custom_css: this.vendor.theme_config.custom_css || ""
};
} else {
themeLog.info('No theme config found, using defaults');
}
// Store original for change detection
this.originalTheme = JSON.parse(JSON.stringify(this.themeData));
const response = await apiClient.get(`/api/v1/admin/vendors/${this.vendorCode}`);
const duration = Date.now() - startTime;
themeLog.info(`Theme loaded in ${duration}ms`, this.themeData);
this.vendor = response;
themeLog.info(`Vendor loaded in ${duration}ms:`, this.vendor.name);
} catch (error) {
themeLog.error('Failed to load vendor:', error);
this.error = 'Failed to load vendor data';
Utils.showToast('Failed to load vendor data', 'error');
}
},
async loadTheme() {
themeLog.info('Loading theme...');
this.loading = true;
this.error = null;
try {
const startTime = Date.now();
const response = await apiClient.get(`/api/v1/admin/vendor-themes/${this.vendorCode}`);
const duration = Date.now() - startTime;
if (response) {
// Merge loaded theme with defaults
this.themeData = {
theme_name: response.theme_name || 'default',
colors: {
...this.themeData.colors,
...(response.colors || {})
},
fonts: {
heading: response.fonts?.heading || this.themeData.fonts.heading,
body: response.fonts?.body || this.themeData.fonts.body
},
layout: {
style: response.layout?.style || this.themeData.layout.style,
header: response.layout?.header || this.themeData.layout.header,
product_card: response.layout?.product_card || this.themeData.layout.product_card
},
branding: {
...this.themeData.branding,
...(response.branding || {})
},
custom_css: response.custom_css || ''
};
themeLog.info(`Theme loaded in ${duration}ms:`, this.themeData.theme_name);
}
} catch (error) {
themeLog.error('Failed to load theme:', error);
this.error = 'Failed to load theme';
Utils.showToast('Failed to load theme', 'error');
} finally {
this.loading = false;
}
},
// Save theme configuration
async saveTheme() {
themeLog.info('Saving theme...');
async loadPresets() {
themeLog.info('Loading presets...');
try {
const startTime = Date.now();
const response = await apiClient.get('/api/v1/admin/vendor-themes/presets');
const duration = Date.now() - startTime;
this.presets = response.presets || [];
themeLog.info(`${this.presets.length} presets loaded in ${duration}ms`);
} catch (error) {
themeLog.warn('Failed to load presets:', error);
// Non-critical error, continue without presets
}
},
// ============================================================================
// PRESET OPERATIONS
// ============================================================================
async applyPreset(presetName) {
themeLog.info(`Applying preset: ${presetName}`);
this.saving = true;
try {
const startTime = Date.now();
// Update vendor with new theme_config
const updateData = {
theme_config: this.themeData
};
const response = await apiClient.put(
`/api/v1/admin/vendors/${this.vendorCode}`,
updateData
const response = await apiClient.post(
`/api/v1/admin/vendor-themes/${this.vendorCode}/preset/${presetName}`
);
const duration = Date.now() - startTime;
themeLog.info(`Theme saved in ${duration}ms`);
// Update vendor data
this.vendor = response;
this.originalTheme = JSON.parse(JSON.stringify(this.themeData));
if (response && response.theme) {
// Update theme data with preset
this.themeData = {
theme_name: response.theme.theme_name,
colors: response.theme.colors || this.themeData.colors,
fonts: response.theme.fonts || this.themeData.fonts,
layout: response.theme.layout || this.themeData.layout,
branding: response.theme.branding || this.themeData.branding,
custom_css: response.theme.custom_css || ''
};
Utils.showToast('Theme saved successfully', 'success');
Utils.showToast(`Applied ${presetName} preset successfully`, 'success');
themeLog.info(`Preset applied in ${duration}ms`);
}
} catch (error) {
themeLog.error('Failed to save theme:', error);
Utils.showToast('Failed to save theme', 'error');
themeLog.error('Failed to apply preset:', error);
const message = error.response?.data?.detail || 'Failed to apply preset';
Utils.showToast(message, 'error');
} finally {
this.saving = false;
}
},
// Apply preset theme
applyPreset(presetName) {
themeLog.info('Applying preset:', presetName);
if (!THEME_PRESETS[presetName]) {
themeLog.error('Unknown preset:', presetName);
async resetToDefault() {
if (!confirm('Are you sure you want to reset to default theme? This will discard all customizations.')) {
return;
}
const preset = THEME_PRESETS[presetName];
// Apply preset values
this.themeData.colors = { ...preset.colors };
this.themeData.fonts = { ...preset.fonts };
this.themeData.layout = { ...preset.layout };
Utils.showToast(`Applied ${presetName} theme preset`, 'success');
},
// Reset to default theme
resetToDefault() {
themeLog.info('Resetting to default theme');
await this.applyPreset('default');
},
// Confirm with user
if (!confirm('Are you sure you want to reset to the default theme? This will discard all customizations.')) {
return;
// ============================================================================
// SAVE OPERATIONS
// ============================================================================
async saveTheme() {
themeLog.info('Saving theme:', this.themeData);
this.saving = true;
this.error = null;
try {
const startTime = Date.now();
const response = await apiClient.put(
`/api/v1/admin/vendor-themes/${this.vendorCode}`,
this.themeData
);
const duration = Date.now() - startTime;
if (response) {
Utils.showToast('Theme saved successfully', 'success');
themeLog.info(`Theme saved in ${duration}ms`);
}
} catch (error) {
themeLog.error('Failed to save theme:', error);
const message = error.response?.data?.detail || 'Failed to save theme';
Utils.showToast(message, 'error');
this.error = message;
} finally {
this.saving = false;
}
this.themeData = {
colors: {
primary: "#6366f1",
secondary: "#8b5cf6",
accent: "#ec4899"
},
fonts: {
heading: "Inter, sans-serif",
body: "Inter, sans-serif"
},
layout: {
style: "grid",
header: "fixed"
},
custom_css: ""
};
Utils.showToast('Theme reset to default', 'info');
},
// Check if theme has unsaved changes
hasChanges() {
if (!this.originalTheme) return false;
return JSON.stringify(this.themeData) !== JSON.stringify(this.originalTheme);
},
// ============================================================================
// HELPER METHODS
// ============================================================================
// Format date helper
formatDate(dateString) {
if (!dateString) return '-';
return Utils.formatDate(dateString);
},
getPreviewStyle() {
return {
'--color-primary': this.themeData.colors.primary,
'--color-secondary': this.themeData.colors.secondary,
'--color-accent': this.themeData.colors.accent,
'--color-background': this.themeData.colors.background,
'--color-text': this.themeData.colors.text,
'--color-border': this.themeData.colors.border,
'--font-heading': this.themeData.fonts.heading,
'--font-body': this.themeData.fonts.body,
};
}
};
}
themeLog.info('Vendor theme module loaded');
// ============================================================================
// MODULE LOADED
// ============================================================================
themeLog.info('Vendor theme editor module loaded');