Multitenant implementation with custom Domain, theme per vendor
This commit is contained in:
@@ -29,6 +29,18 @@ try:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f" ✗ Vendor models failed: {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
|
# Product models
|
||||||
try:
|
try:
|
||||||
from models.database.marketplace_product import MarketplaceProduct
|
from models.database.marketplace_product import MarketplaceProduct
|
||||||
@@ -56,14 +68,14 @@ try:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f" ✗ MarketplaceImportJob model failed: {e}")
|
print(f" ✗ MarketplaceImportJob model failed: {e}")
|
||||||
|
|
||||||
# Customer models (MISSING IN YOUR FILE)
|
# Customer models
|
||||||
try:
|
try:
|
||||||
from models.database.customer import Customer, CustomerAddress
|
from models.database.customer import Customer, CustomerAddress
|
||||||
print(" ✓ Customer models imported (Customer, CustomerAddress)")
|
print(" ✓ Customer models imported (Customer, CustomerAddress)")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f" ✗ Customer models failed: {e}")
|
print(f" ✗ Customer models failed: {e}")
|
||||||
|
|
||||||
# Order models (MISSING IN YOUR FILE)
|
# Order models
|
||||||
try:
|
try:
|
||||||
from models.database.order import Order, OrderItem
|
from models.database.order import Order, OrderItem
|
||||||
print(" ✓ Order models imported (Order, OrderItem)")
|
print(" ✓ Order models imported (Order, OrderItem)")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ This module combines all admin-related API endpoints:
|
|||||||
- Authentication (login/logout)
|
- Authentication (login/logout)
|
||||||
- Vendor management (CRUD, bulk operations)
|
- Vendor management (CRUD, bulk operations)
|
||||||
- Vendor domains management (custom domains, DNS verification)
|
- Vendor domains management (custom domains, DNS verification)
|
||||||
|
- Vendor themes management (theme editor, presets)
|
||||||
- User management (status, roles)
|
- User management (status, roles)
|
||||||
- Dashboard and statistics
|
- Dashboard and statistics
|
||||||
- Marketplace monitoring
|
- Marketplace monitoring
|
||||||
@@ -22,6 +23,7 @@ from . import (
|
|||||||
auth,
|
auth,
|
||||||
vendors,
|
vendors,
|
||||||
vendor_domains,
|
vendor_domains,
|
||||||
|
vendor_themes,
|
||||||
users,
|
users,
|
||||||
dashboard,
|
dashboard,
|
||||||
marketplace,
|
marketplace,
|
||||||
@@ -54,6 +56,9 @@ router.include_router(vendors.router, tags=["admin-vendors"])
|
|||||||
# Include vendor domains management endpoints
|
# Include vendor domains management endpoints
|
||||||
router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
|
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
|
# User Management
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Routes:
|
|||||||
- GET /vendors/{vendor_code} → Vendor details (auth required)
|
- GET /vendors/{vendor_code} → Vendor details (auth required)
|
||||||
- GET /vendors/{vendor_code}/edit → Edit vendor form (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}/domains → Vendor domains management (auth required)
|
||||||
|
- GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required)
|
||||||
- GET /users → User management page (auth required)
|
- GET /users → User management page (auth required)
|
||||||
- GET /imports → Import history page (auth required)
|
- GET /imports → Import history page (auth required)
|
||||||
- GET /settings → Settings page (auth required)
|
- GET /settings → Settings page (auth required)
|
||||||
@@ -205,6 +206,7 @@ async def admin_vendor_theme_page(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Render vendor theme customization page.
|
Render vendor theme customization page.
|
||||||
|
Allows admins to customize colors, fonts, layout, and branding.
|
||||||
"""
|
"""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"admin/vendor-theme.html",
|
"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_fr=vendor.letzshop_csv_url_fr,
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||||
theme_config=vendor.theme_config or {},
|
|
||||||
is_active=vendor.is_active,
|
is_active=vendor.is_active,
|
||||||
is_verified=vendor.is_verified,
|
is_verified=vendor.is_verified,
|
||||||
created_at=vendor.created_at,
|
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_fr=vendor.letzshop_csv_url_fr,
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||||
theme_config=vendor.theme_config or {},
|
|
||||||
is_active=vendor.is_active,
|
is_active=vendor.is_active,
|
||||||
is_verified=vendor.is_verified,
|
is_verified=vendor.is_verified,
|
||||||
created_at=vendor.created_at,
|
created_at=vendor.created_at,
|
||||||
@@ -250,7 +248,6 @@ def update_vendor(
|
|||||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||||
theme_config=vendor.theme_config or {},
|
|
||||||
is_active=vendor.is_active,
|
is_active=vendor.is_active,
|
||||||
is_verified=vendor.is_verified,
|
is_verified=vendor.is_verified,
|
||||||
created_at=vendor.created_at,
|
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_fr=vendor.letzshop_csv_url_fr,
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||||
theme_config=vendor.theme_config or {},
|
|
||||||
is_active=vendor.is_active,
|
is_active=vendor.is_active,
|
||||||
is_verified=vendor.is_verified,
|
is_verified=vendor.is_verified,
|
||||||
created_at=vendor.created_at,
|
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_fr=vendor.letzshop_csv_url_fr,
|
||||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||||
theme_config=vendor.theme_config or {},
|
|
||||||
is_active=vendor.is_active,
|
is_active=vendor.is_active,
|
||||||
is_verified=vendor.is_verified,
|
is_verified=vendor.is_verified,
|
||||||
created_at=vendor.created_at,
|
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_fr": vendor.letzshop_csv_url_fr,
|
||||||
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
|
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
|
||||||
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
|
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
|
||||||
"theme_config": vendor.theme_config,
|
|
||||||
"is_active": vendor.is_active,
|
"is_active": vendor.is_active,
|
||||||
"is_verified": vendor.is_verified,
|
"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_en": vendor.letzshop_csv_url_en,
|
||||||
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
|
"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,
|
BulkOperationException,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Marketplace import jon exceptions
|
# Marketplace import job exceptions
|
||||||
from .marketplace_import_job import (
|
from .marketplace_import_job import (
|
||||||
MarketplaceImportException,
|
MarketplaceImportException,
|
||||||
ImportJobNotFoundException,
|
ImportJobNotFoundException,
|
||||||
@@ -106,6 +106,18 @@ from .vendor_domain import (
|
|||||||
UnauthorizedDomainAccessException,
|
UnauthorizedDomainAccessException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Vendor theme exceptions
|
||||||
|
from .vendor_theme import (
|
||||||
|
VendorThemeNotFoundException,
|
||||||
|
InvalidThemeDataException,
|
||||||
|
ThemePresetNotFoundException,
|
||||||
|
ThemeValidationException,
|
||||||
|
ThemePresetAlreadyAppliedException,
|
||||||
|
InvalidColorFormatException,
|
||||||
|
InvalidFontFamilyException,
|
||||||
|
ThemeOperationException,
|
||||||
|
)
|
||||||
|
|
||||||
# Customer exceptions
|
# Customer exceptions
|
||||||
from .customer import (
|
from .customer import (
|
||||||
CustomerNotFoundException,
|
CustomerNotFoundException,
|
||||||
@@ -235,6 +247,16 @@ __all__ = [
|
|||||||
"MaxDomainsReachedException",
|
"MaxDomainsReachedException",
|
||||||
"UnauthorizedDomainAccessException",
|
"UnauthorizedDomainAccessException",
|
||||||
|
|
||||||
|
# Vendor Theme
|
||||||
|
"VendorThemeNotFoundException",
|
||||||
|
"InvalidThemeDataException",
|
||||||
|
"ThemePresetNotFoundException",
|
||||||
|
"ThemeValidationException",
|
||||||
|
"ThemePresetAlreadyAppliedException",
|
||||||
|
"InvalidColorFormatException",
|
||||||
|
"InvalidFontFamilyException",
|
||||||
|
"ThemeOperationException",
|
||||||
|
|
||||||
# Product exceptions
|
# Product exceptions
|
||||||
"ProductNotFoundException",
|
"ProductNotFoundException",
|
||||||
"ProductAlreadyExistsException",
|
"ProductAlreadyExistsException",
|
||||||
@@ -282,4 +304,4 @@ __all__ = [
|
|||||||
"CannotModifySelfException",
|
"CannotModifySelfException",
|
||||||
"InvalidAdminActionException",
|
"InvalidAdminActionException",
|
||||||
"BulkOperationException",
|
"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_fr=vendor_data.letzshop_csv_url_fr,
|
||||||
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
|
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
|
||||||
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
|
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
|
||||||
theme_config=vendor_data.theme_config or {},
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_verified=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>
|
<body x-cloak>
|
||||||
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
|
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
|
||||||
<!-- Sidebar (server-side included) -->
|
<!-- Sidebar (server-side included) -->
|
||||||
{% include 'partials/sidebar.html' %}
|
{% include 'admin/partials/sidebar.html' %}
|
||||||
|
|
||||||
<div class="flex flex-col flex-1 w-full">
|
<div class="flex flex-col flex-1 w-full">
|
||||||
<!-- Header (server-side included) -->
|
<!-- Header (server-side included) -->
|
||||||
{% include 'partials/header.html' %}
|
{% include 'admin/partials/header.html' %}
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="h-full overflow-y-auto">
|
<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 -->
|
<!-- Top header bar with search, theme toggle, notifications, profile -->
|
||||||
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
|
<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">
|
<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 -->
|
<!-- Desktop sidebar -->
|
||||||
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
|
<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">
|
<div class="py-4 text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
@@ -1,290 +1,459 @@
|
|||||||
{# app/templates/admin/vendor-theme.html #}
|
{# app/templates/admin/vendor-theme.html #}
|
||||||
{% extends "admin/base.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 %}
|
{% block content %}
|
||||||
<div class="container px-6 mx-auto grid">
|
<!-- Page Header -->
|
||||||
<!-- Page Header -->
|
<div class="flex items-center justify-between my-6">
|
||||||
<div class="flex items-center justify-between my-6">
|
<div>
|
||||||
<div>
|
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
Theme Editor
|
||||||
Vendor Theme
|
</h2>
|
||||||
</h2>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
Customize appearance for <span x-text="vendor?.name || '...'"></span>
|
||||||
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>
|
</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 class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div x-show="loading" class="text-center py-12">
|
<!-- Default Preset -->
|
||||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600 animate-spin')"></span>
|
<button @click="applyPreset('default')"
|
||||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading theme...</p>
|
:disabled="saving"
|
||||||
</div>
|
: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">
|
||||||
<!-- Main Content -->
|
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
|
<div class="w-4 h-4 rounded bg-indigo-500"></div>
|
||||||
|
<div class="w-4 h-4 rounded bg-purple-500"></div>
|
||||||
<!-- Theme Configuration Form (2 columns) -->
|
<div class="w-4 h-4 rounded bg-pink-500"></div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
<p class="text-gray-700 dark:text-gray-300">Default</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Colors Section -->
|
<!-- Modern Preset -->
|
||||||
<div class="mb-6">
|
<button @click="applyPreset('modern')"
|
||||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Colors</h4>
|
:disabled="saving"
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
:class="themeData.theme_name === 'modern' ? 'ring-2 ring-purple-500' : ''"
|
||||||
<!-- Primary Color -->
|
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">
|
||||||
<label class="block text-sm">
|
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||||
<span class="text-gray-700 dark:text-gray-400">Primary Color</span>
|
<div class="w-4 h-4 rounded bg-indigo-500"></div>
|
||||||
<div class="flex items-center mt-1 space-x-2">
|
<div class="w-4 h-4 rounded bg-purple-600"></div>
|
||||||
<input type="color"
|
<div class="w-4 h-4 rounded bg-pink-500"></div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="text-gray-700 dark:text-gray-300">Modern</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Typography Section -->
|
<!-- Classic Preset -->
|
||||||
<div class="mb-6">
|
<button @click="applyPreset('classic')"
|
||||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Typography</h4>
|
:disabled="saving"
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
:class="themeData.theme_name === 'classic' ? 'ring-2 ring-purple-500' : ''"
|
||||||
<!-- Heading Font -->
|
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">
|
||||||
<label class="block text-sm">
|
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||||
<span class="text-gray-700 dark:text-gray-400">Heading Font</span>
|
<div class="w-4 h-4 rounded bg-blue-800"></div>
|
||||||
<select x-model="themeData.fonts.heading"
|
<div class="w-4 h-4 rounded bg-purple-700"></div>
|
||||||
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">
|
<div class="w-4 h-4 rounded bg-red-600"></div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="text-gray-700 dark:text-gray-300">Classic</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Layout Section -->
|
<!-- Minimal Preset -->
|
||||||
<div class="mb-6">
|
<button @click="applyPreset('minimal')"
|
||||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Layout</h4>
|
:disabled="saving"
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
:class="themeData.theme_name === 'minimal' ? 'ring-2 ring-purple-500' : ''"
|
||||||
<!-- Product Layout Style -->
|
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">
|
||||||
<label class="block text-sm">
|
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||||
<span class="text-gray-700 dark:text-gray-400">Product Layout</span>
|
<div class="w-4 h-4 rounded bg-black"></div>
|
||||||
<select x-model="themeData.layout.style"
|
<div class="w-4 h-4 rounded bg-gray-600"></div>
|
||||||
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">
|
<div class="w-4 h-4 rounded bg-gray-400"></div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="text-gray-700 dark:text-gray-300">Minimal</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Custom CSS Section -->
|
<!-- Vibrant Preset -->
|
||||||
<div class="mb-6">
|
<button @click="applyPreset('vibrant')"
|
||||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Advanced</h4>
|
:disabled="saving"
|
||||||
<label class="block text-sm">
|
:class="themeData.theme_name === 'vibrant' ? 'ring-2 ring-purple-500' : ''"
|
||||||
<span class="text-gray-700 dark:text-gray-400">Custom CSS</span>
|
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">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||||
Advanced: Add custom CSS rules (use with caution)
|
<div class="w-4 h-4 rounded bg-orange-500"></div>
|
||||||
</p>
|
<div class="w-4 h-4 rounded bg-red-500"></div>
|
||||||
<textarea x-model="themeData.custom_css"
|
<div class="w-4 h-4 rounded bg-purple-600"></div>
|
||||||
rows="6"
|
</div>
|
||||||
placeholder=".my-custom-class { color: red; }"
|
<p class="text-gray-700 dark:text-gray-300">Vibrant</p>
|
||||||
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>
|
</button>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Elegant Preset -->
|
||||||
<div class="flex justify-between items-center">
|
<button @click="applyPreset('elegant')"
|
||||||
<button @click="resetToDefault()"
|
:disabled="saving"
|
||||||
:disabled="saving"
|
:class="themeData.theme_name === 'elegant' ? 'ring-2 ring-purple-500' : ''"
|
||||||
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">
|
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">
|
||||||
Reset to Default
|
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||||
</button>
|
<div class="w-4 h-4 rounded bg-gray-500"></div>
|
||||||
<button @click="saveTheme()"
|
<div class="w-4 h-4 rounded bg-gray-700"></div>
|
||||||
:disabled="saving"
|
<div class="w-4 h-4 rounded bg-amber-600"></div>
|
||||||
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">
|
</div>
|
||||||
<span x-show="!saving">Save Theme</span>
|
<p class="text-gray-700 dark:text-gray-300">Elegant</p>
|
||||||
<span x-show="saving" class="flex items-center">
|
</button>
|
||||||
<span x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
|
|
||||||
Saving...
|
<!-- Nature Preset -->
|
||||||
</span>
|
<button @click="applyPreset('nature')"
|
||||||
</button>
|
:disabled="saving"
|
||||||
</div>
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Preview Panel (1 column) -->
|
<!-- Colors Section -->
|
||||||
<div class="md:col-span-1">
|
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||||
<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">
|
||||||
<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>
|
||||||
Preview
|
Colors
|
||||||
</h3>
|
</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 -->
|
<!-- Secondary Color -->
|
||||||
<div class="space-y-4">
|
<label class="block text-sm">
|
||||||
<!-- Colors Preview -->
|
<span class="text-gray-700 dark:text-gray-400 font-medium">Secondary Color</span>
|
||||||
<div>
|
<p class="text-xs text-gray-500 mb-2">Supporting color for accents</p>
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">COLORS</p>
|
<div class="flex items-center mt-1 space-x-2">
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<input type="color"
|
||||||
<div class="text-center">
|
x-model="themeData.colors.secondary"
|
||||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
|
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
|
||||||
:style="`background-color: ${themeData.colors.primary}`"></div>
|
<input type="text"
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Primary</p>
|
x-model="themeData.colors.secondary"
|
||||||
</div>
|
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 class="text-center">
|
</div>
|
||||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
|
</label>
|
||||||
:style="`background-color: ${themeData.colors.secondary}`"></div>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Secondary</p>
|
<!-- Accent Color -->
|
||||||
</div>
|
<label class="block text-sm">
|
||||||
<div class="text-center">
|
<span class="text-gray-700 dark:text-gray-400 font-medium">Accent Color</span>
|
||||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
|
<p class="text-xs text-gray-500 mb-2">Call-to-action and highlights</p>
|
||||||
:style="`background-color: ${themeData.colors.accent}`"></div>
|
<div class="flex items-center mt-1 space-x-2">
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Accent</p>
|
<input type="color"
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Typography Preview -->
|
<!-- Typography Preview -->
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">TYPOGRAPHY</p>
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">TYPOGRAPHY</p>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||||
<p class="text-lg" :style="`font-family: ${themeData.fonts.heading}`">
|
<p class="text-lg font-bold" :style="`font-family: ${themeData.fonts.heading}`">
|
||||||
Heading Font
|
Heading Font
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm" :style="`font-family: ${themeData.fonts.body}`">
|
<p class="text-sm" :style="`font-family: ${themeData.fonts.body}`">
|
||||||
Body text font example
|
This is body text font example. It will be used for paragraphs and descriptions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Button Preview -->
|
<!-- Button Preview -->
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">BUTTONS</p>
|
<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"
|
<button class="px-4 py-2 text-sm font-medium text-white rounded-lg w-full shadow-sm"
|
||||||
:style="`background-color: ${themeData.colors.primary}`">
|
:style="`background-color: ${themeData.colors.primary}`">
|
||||||
Primary Button
|
Primary Button
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Layout Preview -->
|
<!-- Layout Preview -->
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">LAYOUT</p>
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">LAYOUT</p>
|
||||||
<div class="text-xs space-y-1">
|
<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">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
<span class="font-semibold">Product Layout:</span>
|
<span class="font-semibold">Product Layout:</span>
|
||||||
<span class="capitalize" x-text="themeData.layout.style"></span>
|
<span class="capitalize" x-text="themeData.layout.style"></span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-gray-700 dark:text-gray-300">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
<span class="font-semibold">Header:</span>
|
<span class="font-semibold">Header:</span>
|
||||||
<span class="capitalize" x-text="themeData.layout.header"></span>
|
<span class="capitalize" x-text="themeData.layout.header"></span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Preview Link -->
|
<!-- Preview Link -->
|
||||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<a :href="`http://${vendor?.subdomain}.localhost:8000`"
|
<a :href="`http://${vendor?.subdomain}.localhost:8000`"
|
||||||
target="_blank"
|
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">
|
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('eye', 'w-4 h-4 mr-2')"></span>
|
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
|
||||||
View Live Shop
|
View Live Shop
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
- Extends base.html
|
||||||
- Alpine.js adminDashboard() component
|
- Alpine.js adminDashboard() component
|
||||||
|
|
||||||
4. **`app/templates/partials/header.html`** ✅
|
4. **`app/templates/admin/partials/header.html`** ✅
|
||||||
- Top navigation bar
|
- Top navigation bar
|
||||||
- Updated logout link to /admin/login
|
- Updated logout link to /admin/login
|
||||||
|
|
||||||
5. **`app/templates/partials/sidebar.html`** ✅
|
5. **`app/templates/admin/partials/sidebar.html`** ✅
|
||||||
- Side navigation menu
|
- Side navigation menu
|
||||||
- Updated all links to /admin/* paths
|
- 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:
|
A vendor has ONE active theme stored in the vendor_themes table.
|
||||||
1. Keep existing theme_config JSON field
|
Theme presets available: default, modern, classic, minimal, vibrant
|
||||||
2. Add optional VendorTheme relationship for advanced themes
|
|
||||||
3. Add helper methods for theme access
|
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
|
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text, JSON)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -27,10 +25,6 @@ class Vendor(Base, TimestampMixin):
|
|||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
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 information
|
||||||
contact_email = Column(String)
|
contact_email = Column(String)
|
||||||
contact_phone = Column(String)
|
contact_phone = Column(String)
|
||||||
@@ -49,13 +43,16 @@ class Vendor(Base, TimestampMixin):
|
|||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
is_verified = Column(Boolean, default=False)
|
is_verified = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
# Relationships
|
# Relationships
|
||||||
|
# ========================================================================
|
||||||
owner = relationship("User", back_populates="owned_vendors")
|
owner = relationship("User", back_populates="owned_vendors")
|
||||||
vendor_users = relationship("VendorUser", back_populates="vendor")
|
vendor_users = relationship("VendorUser", back_populates="vendor")
|
||||||
products = relationship("Product", back_populates="vendor")
|
products = relationship("Product", back_populates="vendor")
|
||||||
customers = relationship("Customer", back_populates="vendor")
|
customers = relationship("Customer", back_populates="vendor")
|
||||||
orders = relationship("Order", back_populates="vendor")
|
orders = relationship("Order", back_populates="vendor")
|
||||||
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="vendor")
|
marketplace_import_jobs = relationship("MarketplaceImportJob", back_populates="vendor")
|
||||||
|
|
||||||
domains = relationship(
|
domains = relationship(
|
||||||
"VendorDomain",
|
"VendorDomain",
|
||||||
back_populates="vendor",
|
back_populates="vendor",
|
||||||
@@ -63,16 +60,8 @@ class Vendor(Base, TimestampMixin):
|
|||||||
order_by="VendorDomain.is_primary.desc()"
|
order_by="VendorDomain.is_primary.desc()"
|
||||||
)
|
)
|
||||||
|
|
||||||
theme = relationship(
|
# Single theme relationship (ONE vendor = ONE theme)
|
||||||
"VendorTheme",
|
vendor_theme = relationship(
|
||||||
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(
|
|
||||||
"VendorTheme",
|
"VendorTheme",
|
||||||
back_populates="vendor",
|
back_populates="vendor",
|
||||||
uselist=False,
|
uselist=False,
|
||||||
@@ -86,81 +75,22 @@ class Vendor(Base, TimestampMixin):
|
|||||||
# Theme Helper Methods
|
# Theme Helper Methods
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
@property
|
def get_effective_theme(self) -> dict:
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
Get theme configuration for this vendor.
|
Get active theme for this vendor.
|
||||||
|
|
||||||
Priority:
|
Returns theme from vendor_themes table, or default theme if not set.
|
||||||
1. Advanced theme (VendorTheme) if configured
|
|
||||||
2. theme_config JSON field
|
|
||||||
3. Default theme
|
|
||||||
|
|
||||||
Returns dict with theme configuration.
|
Returns:
|
||||||
|
dict: Theme configuration with colors, fonts, layout, etc.
|
||||||
"""
|
"""
|
||||||
# Priority 1: Advanced theme
|
# Check vendor_themes table
|
||||||
if self.advanced_theme and self.advanced_theme.is_active:
|
if self.vendor_theme and self.vendor_theme.is_active:
|
||||||
return self.advanced_theme.to_dict()
|
return self.vendor_theme.to_dict()
|
||||||
|
|
||||||
# Priority 2: Basic theme_config
|
# Return default theme
|
||||||
if self.theme_config:
|
|
||||||
return self._normalize_theme_config(self.theme_config)
|
|
||||||
|
|
||||||
# Priority 3: Default theme
|
|
||||||
return self._get_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:
|
def _get_default_theme(self) -> dict:
|
||||||
"""Default theme configuration"""
|
"""Default theme configuration"""
|
||||||
return {
|
return {
|
||||||
@@ -180,12 +110,15 @@ class Vendor(Base, TimestampMixin):
|
|||||||
"branding": {
|
"branding": {
|
||||||
"logo": None,
|
"logo": None,
|
||||||
"logo_dark": None,
|
"logo_dark": None,
|
||||||
"favicon": None
|
"favicon": None,
|
||||||
|
"banner": None
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"style": "grid",
|
"style": "grid",
|
||||||
"header": "fixed"
|
"header": "fixed",
|
||||||
|
"product_card": "modern"
|
||||||
},
|
},
|
||||||
|
"social_links": {},
|
||||||
"custom_css": None,
|
"custom_css": None,
|
||||||
"css_variables": {
|
"css_variables": {
|
||||||
"--color-primary": "#6366f1",
|
"--color-primary": "#6366f1",
|
||||||
@@ -199,42 +132,15 @@ class Vendor(Base, TimestampMixin):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
def get_primary_color(self) -> str:
|
||||||
def primary_color(self):
|
"""Get primary color from active theme"""
|
||||||
"""Get primary color from theme"""
|
theme = self.get_effective_theme()
|
||||||
return self.theme.get("colors", {}).get("primary", "#6366f1")
|
return theme.get("colors", {}).get("primary", "#6366f1")
|
||||||
|
|
||||||
@property
|
def get_logo_url(self) -> str:
|
||||||
def logo_url(self):
|
"""Get logo URL from active theme"""
|
||||||
"""Get logo URL from theme"""
|
theme = self.get_effective_theme()
|
||||||
return self.theme.get("branding", {}).get("logo")
|
return 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"]
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Domain Helper Methods
|
# Domain Helper Methods
|
||||||
@@ -257,6 +163,7 @@ class Vendor(Base, TimestampMixin):
|
|||||||
domains.append(domain.domain)
|
domains.append(domain.domain)
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
# Keep your existing VendorUser and Role models unchanged
|
# Keep your existing VendorUser and Role models unchanged
|
||||||
class VendorUser(Base, TimestampMixin):
|
class VendorUser(Base, TimestampMixin):
|
||||||
__tablename__ = "vendor_users"
|
__tablename__ = "vendor_users"
|
||||||
|
|||||||
@@ -3,30 +3,37 @@
|
|||||||
Vendor Theme Configuration Model
|
Vendor Theme Configuration Model
|
||||||
Allows each vendor to customize their shop's appearance
|
Allows each vendor to customize their shop's appearance
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from sqlalchemy import Column, Integer, String, Boolean, Text, JSON, ForeignKey
|
||||||
from sqlalchemy import Column, Integer, String, Boolean, Text, JSON, DateTime, ForeignKey
|
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from models.database.base import TimestampMixin
|
from models.database.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class VendorTheme(Base, TimestampMixin):
|
class VendorTheme(Base, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Stores theme configuration for each vendor's shop.
|
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 colors (primary, secondary, accent)
|
||||||
- Custom fonts
|
- Custom fonts
|
||||||
- Custom logo and favicon
|
- Custom logo and favicon
|
||||||
- Custom CSS overrides
|
- Custom CSS overrides
|
||||||
- Layout preferences
|
- Layout preferences
|
||||||
|
|
||||||
|
Theme presets available: default, modern, classic, minimal, vibrant
|
||||||
"""
|
"""
|
||||||
__tablename__ = "vendor_themes"
|
__tablename__ = "vendor_themes"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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
|
# 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)
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
# Color Scheme (JSON for flexibility)
|
# 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_title_template = Column(String(200), nullable=True) # e.g., "{product_name} - {shop_name}"
|
||||||
meta_description = Column(Text, nullable=True)
|
meta_description = Column(Text, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships - FIXED: back_populates must match the relationship name in Vendor model
|
||||||
vendor = relationship("Vendor", back_populates="theme")
|
vendor = relationship("Vendor", back_populates="vendor_theme")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<VendorTheme(vendor_id={self.vendor_id}, theme_name='{self.theme_name}')>"
|
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_en: Optional[str] = Field(None, description="English CSV URL")
|
||||||
letzshop_csv_url_de: Optional[str] = Field(None, description="German 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")
|
@field_validator("owner_email", "contact_email")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_emails(cls, v):
|
def validate_emails(cls, v):
|
||||||
@@ -122,9 +119,6 @@ class VendorUpdate(BaseModel):
|
|||||||
letzshop_csv_url_en: Optional[str] = None
|
letzshop_csv_url_en: Optional[str] = None
|
||||||
letzshop_csv_url_de: Optional[str] = None
|
letzshop_csv_url_de: Optional[str] = None
|
||||||
|
|
||||||
# Theme Configuration
|
|
||||||
theme_config: Optional[Dict] = None
|
|
||||||
|
|
||||||
# Status (Admin only)
|
# Status (Admin only)
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
is_verified: Optional[bool] = None
|
is_verified: Optional[bool] = None
|
||||||
@@ -171,9 +165,6 @@ class VendorResponse(BaseModel):
|
|||||||
letzshop_csv_url_en: Optional[str]
|
letzshop_csv_url_en: Optional[str]
|
||||||
letzshop_csv_url_de: Optional[str]
|
letzshop_csv_url_de: Optional[str]
|
||||||
|
|
||||||
# Theme Configuration
|
|
||||||
theme_config: Dict
|
|
||||||
|
|
||||||
# Status Flags
|
# Status Flags
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_verified: 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
|
// static/admin/js/vendor-theme.js
|
||||||
/**
|
/**
|
||||||
* Vendor Theme Management Component
|
* Vendor Theme Editor - Alpine.js Component
|
||||||
* Follows the established Alpine.js pattern from FRONTEND_ALPINE_PAGE_TEMPLATE.md
|
* 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 = {
|
const themeLog = {
|
||||||
error: (...args) => THEME_LOG_LEVEL >= 1 && console.error('❌ [THEME ERROR]', ...args),
|
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)
|
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 {
|
return {
|
||||||
// ✅ CRITICAL: Inherit base layout functionality
|
// ✅ CRITICAL: Inherit base layout functionality
|
||||||
...data(),
|
...data(),
|
||||||
@@ -86,193 +31,279 @@ function vendorThemeData() {
|
|||||||
currentPage: 'vendor-theme',
|
currentPage: 'vendor-theme',
|
||||||
|
|
||||||
// Page state
|
// Page state
|
||||||
loading: false,
|
vendorCode: null,
|
||||||
saving: false,
|
|
||||||
vendor: 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: {
|
themeData: {
|
||||||
|
theme_name: 'default',
|
||||||
colors: {
|
colors: {
|
||||||
primary: "#6366f1",
|
primary: '#6366f1',
|
||||||
secondary: "#8b5cf6",
|
secondary: '#8b5cf6',
|
||||||
accent: "#ec4899"
|
accent: '#ec4899',
|
||||||
|
background: '#ffffff',
|
||||||
|
text: '#1f2937',
|
||||||
|
border: '#e5e7eb'
|
||||||
},
|
},
|
||||||
fonts: {
|
fonts: {
|
||||||
heading: "Inter, sans-serif",
|
heading: 'Inter, sans-serif',
|
||||||
body: "Inter, sans-serif"
|
body: 'Inter, sans-serif'
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
style: "grid",
|
style: 'grid',
|
||||||
header: "fixed"
|
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() {
|
async init() {
|
||||||
themeLog.info('=== VENDOR THEME PAGE INITIALIZING ===');
|
themeLog.info('=== VENDOR THEME EDITOR INITIALIZING ===');
|
||||||
|
|
||||||
// Prevent multiple initializations
|
// ✅ CRITICAL: Prevent multiple initializations
|
||||||
if (window._vendorThemeInitialized) {
|
if (window._vendorThemeInitialized) {
|
||||||
themeLog.warn('Page already initialized, skipping...');
|
themeLog.warn('Theme editor already initialized, skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window._vendorThemeInitialized = true;
|
window._vendorThemeInitialized = true;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Get vendor code from URL
|
||||||
|
this.vendorCode = this.getVendorCodeFromURL();
|
||||||
|
themeLog.info('Vendor code:', this.vendorCode);
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
await this.loadVendor();
|
await Promise.all([
|
||||||
await this.loadTheme();
|
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() {
|
// URL HELPERS
|
||||||
themeLog.info('Loading vendor:', this.vendorCode);
|
// ============================================================================
|
||||||
|
|
||||||
try {
|
getVendorCodeFromURL() {
|
||||||
// ✅ CRITICAL: Use lowercase apiClient
|
const pathParts = window.location.pathname.split('/');
|
||||||
const response = await apiClient.get(`/api/v1/admin/vendors/${this.vendorCode}`);
|
const vendorIndex = pathParts.indexOf('vendors');
|
||||||
this.vendor = response;
|
return pathParts[vendorIndex + 1];
|
||||||
themeLog.info('Vendor loaded:', this.vendor.name);
|
|
||||||
} catch (error) {
|
|
||||||
themeLog.error('Failed to load vendor:', error);
|
|
||||||
Utils.showToast('Failed to load vendor', 'error');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load theme configuration
|
// ============================================================================
|
||||||
async loadTheme() {
|
// DATA LOADING
|
||||||
themeLog.info('Loading theme...');
|
// ============================================================================
|
||||||
this.loading = true;
|
|
||||||
|
async loadVendorData() {
|
||||||
|
themeLog.info('Loading vendor data...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const response = await apiClient.get(`/api/v1/admin/vendors/${this.vendorCode}`);
|
||||||
// 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 duration = Date.now() - startTime;
|
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) {
|
} catch (error) {
|
||||||
themeLog.error('Failed to load theme:', error);
|
themeLog.error('Failed to load theme:', error);
|
||||||
|
this.error = 'Failed to load theme';
|
||||||
Utils.showToast('Failed to load theme', 'error');
|
Utils.showToast('Failed to load theme', 'error');
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Save theme configuration
|
async loadPresets() {
|
||||||
async saveTheme() {
|
themeLog.info('Loading presets...');
|
||||||
themeLog.info('Saving theme...');
|
|
||||||
|
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;
|
this.saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const response = await apiClient.post(
|
||||||
// Update vendor with new theme_config
|
`/api/v1/admin/vendor-themes/${this.vendorCode}/preset/${presetName}`
|
||||||
const updateData = {
|
|
||||||
theme_config: this.themeData
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await apiClient.put(
|
|
||||||
`/api/v1/admin/vendors/${this.vendorCode}`,
|
|
||||||
updateData
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
themeLog.info(`Theme saved in ${duration}ms`);
|
|
||||||
|
|
||||||
// Update vendor data
|
if (response && response.theme) {
|
||||||
this.vendor = response;
|
// Update theme data with preset
|
||||||
this.originalTheme = JSON.parse(JSON.stringify(this.themeData));
|
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) {
|
} catch (error) {
|
||||||
themeLog.error('Failed to save theme:', error);
|
themeLog.error('Failed to apply preset:', error);
|
||||||
Utils.showToast('Failed to save theme', 'error');
|
const message = error.response?.data?.detail || 'Failed to apply preset';
|
||||||
|
Utils.showToast(message, 'error');
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Apply preset theme
|
async resetToDefault() {
|
||||||
applyPreset(presetName) {
|
if (!confirm('Are you sure you want to reset to default theme? This will discard all customizations.')) {
|
||||||
themeLog.info('Applying preset:', presetName);
|
|
||||||
|
|
||||||
if (!THEME_PRESETS[presetName]) {
|
|
||||||
themeLog.error('Unknown preset:', presetName);
|
|
||||||
return;
|
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');
|
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.')) {
|
// SAVE OPERATIONS
|
||||||
return;
|
// ============================================================================
|
||||||
|
|
||||||
|
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() {
|
// HELPER METHODS
|
||||||
if (!this.originalTheme) return false;
|
// ============================================================================
|
||||||
return JSON.stringify(this.themeData) !== JSON.stringify(this.originalTheme);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Format date helper
|
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return '-';
|
if (!dateString) return '-';
|
||||||
return Utils.formatDate(dateString);
|
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