Multitenant implementation with custom Domain, theme per vendor
This commit is contained in:
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
326
app/api/v1/admin/vendor_themes.py
Normal file
326
app/api/v1/admin/vendor_themes.py
Normal 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"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
22
app/api/v1/vendor/settings.py
vendored
22
app/api/v1/vendor/settings.py
vendored
@@ -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
311
app/core/theme_presets.py
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
]
|
||||
137
app/exceptions/vendor_theme.py
Normal file
137
app/exceptions/vendor_theme.py
Normal 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
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
516
app/services/vendor_theme_service.py
Normal file
516
app/services/vendor_theme_service.py
Normal 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()
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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! 🎨**
|
||||
@@ -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! 🎨
|
||||
@@ -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!** 🎯
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}')>"
|
||||
|
||||
@@ -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
|
||||
|
||||
84
models/schema/vendor_theme.py
Normal file
84
models/schema/vendor_theme.py
Normal 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")
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user