Multitenant implementation with custom Domain, theme per vendor
This commit is contained in:
@@ -6,6 +6,7 @@ This module combines all admin-related API endpoints:
|
||||
- Authentication (login/logout)
|
||||
- Vendor management (CRUD, bulk operations)
|
||||
- Vendor domains management (custom domains, DNS verification)
|
||||
- Vendor themes management (theme editor, presets)
|
||||
- User management (status, roles)
|
||||
- Dashboard and statistics
|
||||
- Marketplace monitoring
|
||||
@@ -22,6 +23,7 @@ from . import (
|
||||
auth,
|
||||
vendors,
|
||||
vendor_domains,
|
||||
vendor_themes,
|
||||
users,
|
||||
dashboard,
|
||||
marketplace,
|
||||
@@ -54,6 +56,9 @@ router.include_router(vendors.router, tags=["admin-vendors"])
|
||||
# Include vendor domains management endpoints
|
||||
router.include_router(vendor_domains.router, tags=["admin-vendor-domains"])
|
||||
|
||||
# Include vendor themes management endpoints
|
||||
router.include_router(vendor_themes.router, tags=["admin-vendor-themes"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Management
|
||||
|
||||
@@ -17,6 +17,7 @@ Routes:
|
||||
- GET /vendors/{vendor_code} → Vendor details (auth required)
|
||||
- GET /vendors/{vendor_code}/edit → Edit vendor form (auth required)
|
||||
- GET /vendors/{vendor_code}/domains → Vendor domains management (auth required)
|
||||
- GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required)
|
||||
- GET /users → User management page (auth required)
|
||||
- GET /imports → Import history page (auth required)
|
||||
- GET /settings → Settings page (auth required)
|
||||
@@ -205,6 +206,7 @@ async def admin_vendor_theme_page(
|
||||
):
|
||||
"""
|
||||
Render vendor theme customization page.
|
||||
Allows admins to customize colors, fonts, layout, and branding.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/vendor-theme.html",
|
||||
|
||||
326
app/api/v1/admin/vendor_themes.py
Normal file
326
app/api/v1/admin/vendor_themes.py
Normal file
@@ -0,0 +1,326 @@
|
||||
# app/api/v1/admin/vendor_themes.py
|
||||
"""
|
||||
Vendor theme management endpoints for admin.
|
||||
|
||||
These endpoints allow admins to:
|
||||
- View vendor themes
|
||||
- Apply theme presets
|
||||
- Customize theme settings
|
||||
- Reset themes to default
|
||||
|
||||
All operations use the service layer for business logic.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_user, get_db
|
||||
from app.services.vendor_theme_service import vendor_theme_service
|
||||
from app.exceptions.vendor import VendorNotFoundException
|
||||
from app.exceptions.vendor_theme import (
|
||||
VendorThemeNotFoundException,
|
||||
ThemePresetNotFoundException,
|
||||
ThemeValidationException,
|
||||
ThemeOperationException,
|
||||
InvalidColorFormatException,
|
||||
InvalidFontFamilyException
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.schema.vendor_theme import (
|
||||
VendorThemeResponse,
|
||||
VendorThemeUpdate,
|
||||
ThemePresetResponse,
|
||||
ThemePresetListResponse
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/vendor-themes")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PRESET ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/presets", response_model=ThemePresetListResponse)
|
||||
async def get_theme_presets(
|
||||
current_admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
Get all available theme presets with preview information.
|
||||
|
||||
Returns list of presets that can be applied to vendor themes.
|
||||
Each preset includes color palette, fonts, and layout configuration.
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- List of available theme presets with preview data
|
||||
"""
|
||||
logger.info("Getting theme presets")
|
||||
|
||||
try:
|
||||
presets = vendor_theme_service.get_available_presets()
|
||||
return {"presets": presets}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get theme presets: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve theme presets"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME RETRIEVAL
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/{vendor_code}", response_model=VendorThemeResponse)
|
||||
async def get_vendor_theme(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
Get theme configuration for a vendor.
|
||||
|
||||
Returns the vendor's custom theme if exists, otherwise returns default theme.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Complete theme configuration including colors, fonts, layout, and branding
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor not found
|
||||
- `500`: Internal server error
|
||||
"""
|
||||
logger.info(f"Getting theme for vendor: {vendor_code}")
|
||||
|
||||
try:
|
||||
theme = vendor_theme_service.get_theme(db, vendor_code)
|
||||
return theme
|
||||
|
||||
except VendorNotFoundException:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vendor '{vendor_code}' not found"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get theme for vendor {vendor_code}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve theme"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME UPDATE
|
||||
# ============================================================================
|
||||
|
||||
@router.put("/{vendor_code}", response_model=VendorThemeResponse)
|
||||
async def update_vendor_theme(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
theme_data: VendorThemeUpdate = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
Update or create theme for a vendor.
|
||||
|
||||
Accepts partial updates - only provided fields are updated.
|
||||
If vendor has no theme, a new one is created.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
|
||||
**Request Body:**
|
||||
- `theme_name`: Optional theme name
|
||||
- `colors`: Optional color palette (primary, secondary, accent, etc.)
|
||||
- `fonts`: Optional font settings (heading, body)
|
||||
- `layout`: Optional layout settings (style, header, product_card)
|
||||
- `branding`: Optional branding assets (logo, favicon, etc.)
|
||||
- `custom_css`: Optional custom CSS rules
|
||||
- `social_links`: Optional social media links
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Updated theme configuration
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor not found
|
||||
- `422`: Validation error (invalid colors, fonts, or layout values)
|
||||
- `500`: Internal server error
|
||||
"""
|
||||
logger.info(f"Updating theme for vendor: {vendor_code}")
|
||||
|
||||
try:
|
||||
theme = vendor_theme_service.update_theme(db, vendor_code, theme_data)
|
||||
return theme.to_dict()
|
||||
|
||||
except VendorNotFoundException:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vendor '{vendor_code}' not found"
|
||||
)
|
||||
|
||||
except (ThemeValidationException, InvalidColorFormatException, InvalidFontFamilyException) as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=e.message
|
||||
)
|
||||
|
||||
except ThemeOperationException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=e.message
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update theme for vendor {vendor_code}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update theme"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PRESET APPLICATION
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/{vendor_code}/preset/{preset_name}")
|
||||
async def apply_theme_preset(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
preset_name: str = Path(..., description="Preset name"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
Apply a theme preset to a vendor.
|
||||
|
||||
Replaces the vendor's current theme with the selected preset.
|
||||
Available presets can be retrieved from the `/presets` endpoint.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
- `preset_name`: Name of preset to apply (e.g., 'modern', 'classic')
|
||||
|
||||
**Available Presets:**
|
||||
- `default`: Clean and professional
|
||||
- `modern`: Contemporary tech-inspired
|
||||
- `classic`: Traditional and trustworthy
|
||||
- `minimal`: Ultra-clean black and white
|
||||
- `vibrant`: Bold and energetic
|
||||
- `elegant`: Sophisticated gray tones
|
||||
- `nature`: Fresh and eco-friendly
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Success message and applied theme configuration
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor or preset not found
|
||||
- `500`: Internal server error
|
||||
"""
|
||||
logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}")
|
||||
|
||||
try:
|
||||
theme = vendor_theme_service.apply_theme_preset(db, vendor_code, preset_name)
|
||||
|
||||
return {
|
||||
"message": f"Applied {preset_name} preset successfully",
|
||||
"theme": theme.to_dict()
|
||||
}
|
||||
|
||||
except VendorNotFoundException:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vendor '{vendor_code}' not found"
|
||||
)
|
||||
|
||||
except ThemePresetNotFoundException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=e.message
|
||||
)
|
||||
|
||||
except ThemeOperationException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=e.message
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply preset to vendor {vendor_code}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to apply preset"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# THEME DELETION
|
||||
# ============================================================================
|
||||
|
||||
@router.delete("/{vendor_code}")
|
||||
async def delete_vendor_theme(
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
Delete custom theme for a vendor.
|
||||
|
||||
Removes the vendor's custom theme. After deletion, the vendor
|
||||
will revert to using the default platform theme.
|
||||
|
||||
**Path Parameters:**
|
||||
- `vendor_code`: Vendor code (e.g., VENDOR001)
|
||||
|
||||
**Permissions:** Admin only
|
||||
|
||||
**Returns:**
|
||||
- Success message
|
||||
|
||||
**Errors:**
|
||||
- `404`: Vendor not found or vendor has no custom theme
|
||||
- `500`: Internal server error
|
||||
"""
|
||||
logger.info(f"Deleting theme for vendor: {vendor_code}")
|
||||
|
||||
try:
|
||||
result = vendor_theme_service.delete_theme(db, vendor_code)
|
||||
return result
|
||||
|
||||
except VendorNotFoundException:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vendor '{vendor_code}' not found"
|
||||
)
|
||||
|
||||
except VendorThemeNotFoundException:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Vendor '{vendor_code}' has no custom theme"
|
||||
)
|
||||
|
||||
except ThemeOperationException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=e.message
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete theme for vendor {vendor_code}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete theme"
|
||||
)
|
||||
@@ -113,7 +113,6 @@ def create_vendor_with_owner(
|
||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||
theme_config=vendor.theme_config or {},
|
||||
is_active=vendor.is_active,
|
||||
is_verified=vendor.is_verified,
|
||||
created_at=vendor.created_at,
|
||||
@@ -197,7 +196,6 @@ def get_vendor_details(
|
||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||
theme_config=vendor.theme_config or {},
|
||||
is_active=vendor.is_active,
|
||||
is_verified=vendor.is_verified,
|
||||
created_at=vendor.created_at,
|
||||
@@ -250,7 +248,6 @@ def update_vendor(
|
||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||
theme_config=vendor.theme_config or {},
|
||||
is_active=vendor.is_active,
|
||||
is_verified=vendor.is_verified,
|
||||
created_at=vendor.created_at,
|
||||
@@ -354,7 +351,6 @@ def toggle_vendor_verification(
|
||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||
theme_config=vendor.theme_config or {},
|
||||
is_active=vendor.is_active,
|
||||
is_verified=vendor.is_verified,
|
||||
created_at=vendor.created_at,
|
||||
@@ -406,7 +402,6 @@ def toggle_vendor_status(
|
||||
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
||||
theme_config=vendor.theme_config or {},
|
||||
is_active=vendor.is_active,
|
||||
is_verified=vendor.is_verified,
|
||||
created_at=vendor.created_at,
|
||||
|
||||
22
app/api/v1/vendor/settings.py
vendored
22
app/api/v1/vendor/settings.py
vendored
@@ -37,7 +37,6 @@ def get_vendor_settings(
|
||||
"letzshop_csv_url_fr": vendor.letzshop_csv_url_fr,
|
||||
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
|
||||
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
|
||||
"theme_config": vendor.theme_config,
|
||||
"is_active": vendor.is_active,
|
||||
"is_verified": vendor.is_verified,
|
||||
}
|
||||
@@ -72,24 +71,3 @@ def update_marketplace_settings(
|
||||
"letzshop_csv_url_en": vendor.letzshop_csv_url_en,
|
||||
"letzshop_csv_url_de": vendor.letzshop_csv_url_de,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/theme")
|
||||
def update_theme_settings(
|
||||
theme_config: dict,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update vendor theme configuration."""
|
||||
if not vendor_service.can_update_vendor(vendor, current_user):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
vendor.theme_config = theme_config
|
||||
db.commit()
|
||||
db.refresh(vendor)
|
||||
|
||||
return {
|
||||
"message": "Theme settings updated successfully",
|
||||
"theme_config": vendor.theme_config,
|
||||
}
|
||||
|
||||
311
app/core/theme_presets.py
Normal file
311
app/core/theme_presets.py
Normal file
@@ -0,0 +1,311 @@
|
||||
# app/core/theme_presets.py
|
||||
"""
|
||||
Theme presets for vendor shops.
|
||||
|
||||
Presets define default color schemes, fonts, and layouts that vendors can choose from.
|
||||
Each preset provides a complete theme configuration that can be customized further.
|
||||
"""
|
||||
|
||||
from models.database.vendor_theme import VendorTheme
|
||||
|
||||
THEME_PRESETS = {
|
||||
"default": {
|
||||
"colors": {
|
||||
"primary": "#6366f1", # Indigo
|
||||
"secondary": "#8b5cf6", # Purple
|
||||
"accent": "#ec4899", # Pink
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#e5e7eb" # Gray-200
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Inter, sans-serif",
|
||||
"body": "Inter, sans-serif"
|
||||
},
|
||||
"layout": {
|
||||
"style": "grid",
|
||||
"header": "fixed",
|
||||
"product_card": "modern"
|
||||
}
|
||||
},
|
||||
|
||||
"modern": {
|
||||
"colors": {
|
||||
"primary": "#6366f1", # Indigo - Modern tech look
|
||||
"secondary": "#8b5cf6", # Purple
|
||||
"accent": "#ec4899", # Pink
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#e5e7eb" # Gray-200
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Inter, sans-serif",
|
||||
"body": "Inter, sans-serif"
|
||||
},
|
||||
"layout": {
|
||||
"style": "grid",
|
||||
"header": "fixed",
|
||||
"product_card": "modern"
|
||||
}
|
||||
},
|
||||
|
||||
"classic": {
|
||||
"colors": {
|
||||
"primary": "#1e40af", # Dark blue - Traditional
|
||||
"secondary": "#7c3aed", # Purple
|
||||
"accent": "#dc2626", # Red
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#d1d5db" # Gray-300
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Georgia, serif",
|
||||
"body": "Arial, sans-serif"
|
||||
},
|
||||
"layout": {
|
||||
"style": "list",
|
||||
"header": "static",
|
||||
"product_card": "classic"
|
||||
}
|
||||
},
|
||||
|
||||
"minimal": {
|
||||
"colors": {
|
||||
"primary": "#000000", # Black - Ultra minimal
|
||||
"secondary": "#404040", # Dark gray
|
||||
"accent": "#666666", # Medium gray
|
||||
"background": "#ffffff", # White
|
||||
"text": "#000000", # Black
|
||||
"border": "#e5e7eb" # Light gray
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Helvetica, sans-serif",
|
||||
"body": "Helvetica, sans-serif"
|
||||
},
|
||||
"layout": {
|
||||
"style": "grid",
|
||||
"header": "transparent",
|
||||
"product_card": "minimal"
|
||||
}
|
||||
},
|
||||
|
||||
"vibrant": {
|
||||
"colors": {
|
||||
"primary": "#f59e0b", # Orange - Bold & energetic
|
||||
"secondary": "#ef4444", # Red
|
||||
"accent": "#8b5cf6", # Purple
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#fbbf24" # Yellow
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Poppins, sans-serif",
|
||||
"body": "Open Sans, sans-serif"
|
||||
},
|
||||
"layout": {
|
||||
"style": "masonry",
|
||||
"header": "fixed",
|
||||
"product_card": "modern"
|
||||
}
|
||||
},
|
||||
|
||||
"elegant": {
|
||||
"colors": {
|
||||
"primary": "#6b7280", # Gray - Sophisticated
|
||||
"secondary": "#374151", # Dark gray
|
||||
"accent": "#d97706", # Amber
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#e5e7eb" # Gray-200
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Playfair Display, serif",
|
||||
"body": "Lato, sans-serif"
|
||||
},
|
||||
"layout": {
|
||||
"style": "grid",
|
||||
"header": "fixed",
|
||||
"product_card": "classic"
|
||||
}
|
||||
},
|
||||
|
||||
"nature": {
|
||||
"colors": {
|
||||
"primary": "#059669", # Green - Natural & eco
|
||||
"secondary": "#10b981", # Emerald
|
||||
"accent": "#f59e0b", # Amber
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#d1fae5" # Light green
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Montserrat, sans-serif",
|
||||
"body": "Open Sans, sans-serif"
|
||||
},
|
||||
"layout": {
|
||||
"style": "grid",
|
||||
"header": "fixed",
|
||||
"product_card": "modern"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_preset(preset_name: str) -> dict:
|
||||
"""
|
||||
Get a theme preset by name.
|
||||
|
||||
Args:
|
||||
preset_name: Name of the preset (e.g., 'modern', 'classic')
|
||||
|
||||
Returns:
|
||||
dict: Theme configuration
|
||||
|
||||
Raises:
|
||||
ValueError: If preset name is unknown
|
||||
"""
|
||||
if preset_name not in THEME_PRESETS:
|
||||
available = ", ".join(THEME_PRESETS.keys())
|
||||
raise ValueError(f"Unknown preset: {preset_name}. Available: {available}")
|
||||
|
||||
return THEME_PRESETS[preset_name]
|
||||
|
||||
|
||||
def apply_preset(theme: VendorTheme, preset_name: str) -> VendorTheme:
|
||||
"""
|
||||
Apply a preset to a vendor theme.
|
||||
|
||||
Args:
|
||||
theme: VendorTheme instance to update
|
||||
preset_name: Name of the preset to apply
|
||||
|
||||
Returns:
|
||||
VendorTheme: Updated theme instance
|
||||
|
||||
Raises:
|
||||
ValueError: If preset name is unknown
|
||||
|
||||
Example:
|
||||
theme = VendorTheme(vendor_id=1)
|
||||
apply_preset(theme, "modern")
|
||||
db.add(theme)
|
||||
db.commit()
|
||||
"""
|
||||
preset = get_preset(preset_name)
|
||||
|
||||
# Set theme name
|
||||
theme.theme_name = preset_name
|
||||
|
||||
# Apply colors (all of them!)
|
||||
theme.colors = preset["colors"]
|
||||
|
||||
# Apply fonts
|
||||
theme.font_family_heading = preset["fonts"]["heading"]
|
||||
theme.font_family_body = preset["fonts"]["body"]
|
||||
|
||||
# Apply layout settings
|
||||
theme.layout_style = preset["layout"]["style"]
|
||||
theme.header_style = preset["layout"]["header"]
|
||||
theme.product_card_style = preset["layout"]["product_card"]
|
||||
|
||||
# Mark as active
|
||||
theme.is_active = True
|
||||
|
||||
return theme
|
||||
|
||||
|
||||
def get_available_presets() -> list[str]:
|
||||
"""
|
||||
Get list of available preset names.
|
||||
|
||||
Returns:
|
||||
list: Available preset names
|
||||
"""
|
||||
return list(THEME_PRESETS.keys())
|
||||
|
||||
|
||||
def get_preset_preview(preset_name: str) -> dict:
|
||||
"""
|
||||
Get preview information for a preset (for UI display).
|
||||
|
||||
Args:
|
||||
preset_name: Name of the preset
|
||||
|
||||
Returns:
|
||||
dict: Preview info with colors, fonts, description
|
||||
"""
|
||||
preset = get_preset(preset_name)
|
||||
|
||||
descriptions = {
|
||||
"default": "Clean and professional - perfect for getting started",
|
||||
"modern": "Contemporary tech-inspired design with vibrant colors",
|
||||
"classic": "Traditional and trustworthy with serif typography",
|
||||
"minimal": "Ultra-clean black and white aesthetic",
|
||||
"vibrant": "Bold and energetic with bright accent colors",
|
||||
"elegant": "Sophisticated gray tones with refined typography",
|
||||
"nature": "Fresh and eco-friendly green color palette"
|
||||
}
|
||||
|
||||
return {
|
||||
"name": preset_name,
|
||||
"description": descriptions.get(preset_name, ""),
|
||||
"primary_color": preset["colors"]["primary"],
|
||||
"secondary_color": preset["colors"]["secondary"],
|
||||
"accent_color": preset["colors"]["accent"],
|
||||
"heading_font": preset["fonts"]["heading"],
|
||||
"body_font": preset["fonts"]["body"],
|
||||
"layout_style": preset["layout"]["style"],
|
||||
}
|
||||
|
||||
|
||||
def create_custom_preset(
|
||||
colors: dict,
|
||||
fonts: dict,
|
||||
layout: dict,
|
||||
name: str = "custom"
|
||||
) -> dict:
|
||||
"""
|
||||
Create a custom preset from provided settings.
|
||||
|
||||
Args:
|
||||
colors: Dict with primary, secondary, accent, background, text, border
|
||||
fonts: Dict with heading and body fonts
|
||||
layout: Dict with style, header, product_card
|
||||
name: Name for the custom preset
|
||||
|
||||
Returns:
|
||||
dict: Custom preset configuration
|
||||
|
||||
Example:
|
||||
custom = create_custom_preset(
|
||||
colors={"primary": "#ff0000", "secondary": "#00ff00", ...},
|
||||
fonts={"heading": "Arial", "body": "Arial"},
|
||||
layout={"style": "grid", "header": "fixed", "product_card": "modern"},
|
||||
name="my_custom_theme"
|
||||
)
|
||||
"""
|
||||
# Validate colors
|
||||
required_colors = ["primary", "secondary", "accent", "background", "text", "border"]
|
||||
for color_key in required_colors:
|
||||
if color_key not in colors:
|
||||
colors[color_key] = THEME_PRESETS["default"]["colors"][color_key]
|
||||
|
||||
# Validate fonts
|
||||
if "heading" not in fonts:
|
||||
fonts["heading"] = "Inter, sans-serif"
|
||||
if "body" not in fonts:
|
||||
fonts["body"] = "Inter, sans-serif"
|
||||
|
||||
# Validate layout
|
||||
if "style" not in layout:
|
||||
layout["style"] = "grid"
|
||||
if "header" not in layout:
|
||||
layout["header"] = "fixed"
|
||||
if "product_card" not in layout:
|
||||
layout["product_card"] = "modern"
|
||||
|
||||
return {
|
||||
"colors": colors,
|
||||
"fonts": fonts,
|
||||
"layout": layout
|
||||
}
|
||||
@@ -43,7 +43,7 @@ from .admin import (
|
||||
BulkOperationException,
|
||||
)
|
||||
|
||||
# Marketplace import jon exceptions
|
||||
# Marketplace import job exceptions
|
||||
from .marketplace_import_job import (
|
||||
MarketplaceImportException,
|
||||
ImportJobNotFoundException,
|
||||
@@ -106,6 +106,18 @@ from .vendor_domain import (
|
||||
UnauthorizedDomainAccessException,
|
||||
)
|
||||
|
||||
# Vendor theme exceptions
|
||||
from .vendor_theme import (
|
||||
VendorThemeNotFoundException,
|
||||
InvalidThemeDataException,
|
||||
ThemePresetNotFoundException,
|
||||
ThemeValidationException,
|
||||
ThemePresetAlreadyAppliedException,
|
||||
InvalidColorFormatException,
|
||||
InvalidFontFamilyException,
|
||||
ThemeOperationException,
|
||||
)
|
||||
|
||||
# Customer exceptions
|
||||
from .customer import (
|
||||
CustomerNotFoundException,
|
||||
@@ -235,6 +247,16 @@ __all__ = [
|
||||
"MaxDomainsReachedException",
|
||||
"UnauthorizedDomainAccessException",
|
||||
|
||||
# Vendor Theme
|
||||
"VendorThemeNotFoundException",
|
||||
"InvalidThemeDataException",
|
||||
"ThemePresetNotFoundException",
|
||||
"ThemeValidationException",
|
||||
"ThemePresetAlreadyAppliedException",
|
||||
"InvalidColorFormatException",
|
||||
"InvalidFontFamilyException",
|
||||
"ThemeOperationException",
|
||||
|
||||
# Product exceptions
|
||||
"ProductNotFoundException",
|
||||
"ProductAlreadyExistsException",
|
||||
@@ -282,4 +304,4 @@ __all__ = [
|
||||
"CannotModifySelfException",
|
||||
"InvalidAdminActionException",
|
||||
"BulkOperationException",
|
||||
]
|
||||
]
|
||||
137
app/exceptions/vendor_theme.py
Normal file
137
app/exceptions/vendor_theme.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# app/exceptions/vendor_theme.py
|
||||
"""
|
||||
Vendor theme management specific exceptions.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import (
|
||||
ResourceNotFoundException,
|
||||
ConflictException,
|
||||
ValidationException,
|
||||
BusinessLogicException
|
||||
)
|
||||
|
||||
|
||||
class VendorThemeNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a vendor theme is not found."""
|
||||
|
||||
def __init__(self, vendor_identifier: str):
|
||||
super().__init__(
|
||||
resource_type="VendorTheme",
|
||||
identifier=vendor_identifier,
|
||||
message=f"Theme for vendor '{vendor_identifier}' not found",
|
||||
error_code="VENDOR_THEME_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class InvalidThemeDataException(ValidationException):
|
||||
"""Raised when theme data is invalid."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Invalid theme data",
|
||||
field: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "INVALID_THEME_DATA"
|
||||
|
||||
|
||||
class ThemePresetNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a theme preset is not found."""
|
||||
|
||||
def __init__(self, preset_name: str, available_presets: Optional[list] = None):
|
||||
details = {"preset_name": preset_name}
|
||||
if available_presets:
|
||||
details["available_presets"] = available_presets
|
||||
|
||||
super().__init__(
|
||||
resource_type="ThemePreset",
|
||||
identifier=preset_name,
|
||||
message=f"Theme preset '{preset_name}' not found",
|
||||
error_code="THEME_PRESET_NOT_FOUND",
|
||||
)
|
||||
self.details = details
|
||||
|
||||
|
||||
class ThemeValidationException(ValidationException):
|
||||
"""Raised when theme validation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Theme validation failed",
|
||||
field: Optional[str] = None,
|
||||
validation_errors: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
details = {}
|
||||
if validation_errors:
|
||||
details["validation_errors"] = validation_errors
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "THEME_VALIDATION_FAILED"
|
||||
|
||||
|
||||
class ThemePresetAlreadyAppliedException(BusinessLogicException):
|
||||
"""Raised when trying to apply the same preset that's already active."""
|
||||
|
||||
def __init__(self, preset_name: str, vendor_code: str):
|
||||
super().__init__(
|
||||
message=f"Preset '{preset_name}' is already applied to vendor '{vendor_code}'",
|
||||
error_code="THEME_PRESET_ALREADY_APPLIED",
|
||||
details={
|
||||
"preset_name": preset_name,
|
||||
"vendor_code": vendor_code
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvalidColorFormatException(ValidationException):
|
||||
"""Raised when color format is invalid."""
|
||||
|
||||
def __init__(self, color_value: str, field: str):
|
||||
super().__init__(
|
||||
message=f"Invalid color format: {color_value}",
|
||||
field=field,
|
||||
details={"color_value": color_value},
|
||||
)
|
||||
self.error_code = "INVALID_COLOR_FORMAT"
|
||||
|
||||
|
||||
class InvalidFontFamilyException(ValidationException):
|
||||
"""Raised when font family is invalid."""
|
||||
|
||||
def __init__(self, font_value: str, field: str):
|
||||
super().__init__(
|
||||
message=f"Invalid font family: {font_value}",
|
||||
field=field,
|
||||
details={"font_value": font_value},
|
||||
)
|
||||
self.error_code = "INVALID_FONT_FAMILY"
|
||||
|
||||
|
||||
class ThemeOperationException(BusinessLogicException):
|
||||
"""Raised when theme operation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
vendor_code: str,
|
||||
reason: str
|
||||
):
|
||||
super().__init__(
|
||||
message=f"Theme operation '{operation}' failed for vendor '{vendor_code}': {reason}",
|
||||
error_code="THEME_OPERATION_FAILED",
|
||||
details={
|
||||
"operation": operation,
|
||||
"vendor_code": vendor_code,
|
||||
"reason": reason
|
||||
},
|
||||
)
|
||||
@@ -184,7 +184,6 @@ class AdminService:
|
||||
letzshop_csv_url_fr=vendor_data.letzshop_csv_url_fr,
|
||||
letzshop_csv_url_en=vendor_data.letzshop_csv_url_en,
|
||||
letzshop_csv_url_de=vendor_data.letzshop_csv_url_de,
|
||||
theme_config=vendor_data.theme_config or {},
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
|
||||
516
app/services/vendor_theme_service.py
Normal file
516
app/services/vendor_theme_service.py
Normal file
@@ -0,0 +1,516 @@
|
||||
# app/services/vendor_theme_service.py
|
||||
"""
|
||||
Vendor Theme Service
|
||||
|
||||
Business logic for vendor theme management.
|
||||
Handles theme CRUD operations, preset application, and validation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.vendor_theme import VendorTheme
|
||||
from models.schema.vendor_theme import (
|
||||
VendorThemeUpdate,
|
||||
ThemePresetPreview
|
||||
)
|
||||
from app.exceptions.vendor import VendorNotFoundException
|
||||
from app.exceptions.vendor_theme import (
|
||||
VendorThemeNotFoundException,
|
||||
InvalidThemeDataException,
|
||||
ThemePresetNotFoundException,
|
||||
ThemeValidationException,
|
||||
InvalidColorFormatException,
|
||||
InvalidFontFamilyException,
|
||||
ThemePresetAlreadyAppliedException,
|
||||
ThemeOperationException
|
||||
)
|
||||
from app.core.theme_presets import (
|
||||
apply_preset,
|
||||
get_available_presets,
|
||||
get_preset_preview,
|
||||
THEME_PRESETS
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VendorThemeService:
|
||||
"""
|
||||
Service for managing vendor themes.
|
||||
|
||||
This service handles:
|
||||
- Theme retrieval and creation
|
||||
- Theme updates and validation
|
||||
- Preset application
|
||||
- Default theme generation
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the vendor theme service."""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR RETRIEVAL
|
||||
# ============================================================================
|
||||
|
||||
def _get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor:
|
||||
"""
|
||||
Get vendor by code or raise exception.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code to lookup
|
||||
|
||||
Returns:
|
||||
Vendor object
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
"""
|
||||
vendor = db.query(Vendor).filter(
|
||||
Vendor.vendor_code == vendor_code.upper()
|
||||
).first()
|
||||
|
||||
if not vendor:
|
||||
self.logger.warning(f"Vendor not found: {vendor_code}")
|
||||
raise VendorNotFoundException(vendor_code, identifier_type="code")
|
||||
|
||||
return vendor
|
||||
|
||||
# ============================================================================
|
||||
# THEME RETRIEVAL
|
||||
# ============================================================================
|
||||
|
||||
def get_theme(self, db: Session, vendor_code: str) -> Dict:
|
||||
"""
|
||||
Get theme for vendor. Returns default if no custom theme exists.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code
|
||||
|
||||
Returns:
|
||||
Theme dictionary
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
"""
|
||||
self.logger.info(f"Getting theme for vendor: {vendor_code}")
|
||||
|
||||
# Verify vendor exists
|
||||
vendor = self._get_vendor_by_code(db, vendor_code)
|
||||
|
||||
# Get theme
|
||||
theme = db.query(VendorTheme).filter(
|
||||
VendorTheme.vendor_id == vendor.id
|
||||
).first()
|
||||
|
||||
if not theme:
|
||||
self.logger.info(f"No custom theme for vendor {vendor_code}, returning default")
|
||||
return self._get_default_theme()
|
||||
|
||||
return theme.to_dict()
|
||||
|
||||
def _get_default_theme(self) -> Dict:
|
||||
"""
|
||||
Get default theme configuration.
|
||||
|
||||
Returns:
|
||||
Default theme dictionary
|
||||
"""
|
||||
return {
|
||||
"theme_name": "default",
|
||||
"colors": {
|
||||
"primary": "#6366f1",
|
||||
"secondary": "#8b5cf6",
|
||||
"accent": "#ec4899",
|
||||
"background": "#ffffff",
|
||||
"text": "#1f2937",
|
||||
"border": "#e5e7eb"
|
||||
},
|
||||
"fonts": {
|
||||
"heading": "Inter, sans-serif",
|
||||
"body": "Inter, sans-serif"
|
||||
},
|
||||
"branding": {
|
||||
"logo": None,
|
||||
"logo_dark": None,
|
||||
"favicon": None,
|
||||
"banner": None
|
||||
},
|
||||
"layout": {
|
||||
"style": "grid",
|
||||
"header": "fixed",
|
||||
"product_card": "modern"
|
||||
},
|
||||
"social_links": {},
|
||||
"custom_css": None,
|
||||
"css_variables": {
|
||||
"--color-primary": "#6366f1",
|
||||
"--color-secondary": "#8b5cf6",
|
||||
"--color-accent": "#ec4899",
|
||||
"--color-background": "#ffffff",
|
||||
"--color-text": "#1f2937",
|
||||
"--color-border": "#e5e7eb",
|
||||
"--font-heading": "Inter, sans-serif",
|
||||
"--font-body": "Inter, sans-serif",
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# THEME UPDATE
|
||||
# ============================================================================
|
||||
|
||||
def update_theme(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_code: str,
|
||||
theme_data: VendorThemeUpdate
|
||||
) -> VendorTheme:
|
||||
"""
|
||||
Update or create theme for vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code
|
||||
theme_data: Theme update data
|
||||
|
||||
Returns:
|
||||
Updated VendorTheme object
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
ThemeValidationException: If theme data invalid
|
||||
ThemeOperationException: If update fails
|
||||
"""
|
||||
self.logger.info(f"Updating theme for vendor: {vendor_code}")
|
||||
|
||||
try:
|
||||
# Verify vendor exists
|
||||
vendor = self._get_vendor_by_code(db, vendor_code)
|
||||
|
||||
# Get or create theme
|
||||
theme = db.query(VendorTheme).filter(
|
||||
VendorTheme.vendor_id == vendor.id
|
||||
).first()
|
||||
|
||||
if not theme:
|
||||
self.logger.info(f"Creating new theme for vendor {vendor_code}")
|
||||
theme = VendorTheme(vendor_id=vendor.id, is_active=True)
|
||||
db.add(theme)
|
||||
|
||||
# Validate theme data before applying
|
||||
self._validate_theme_data(theme_data)
|
||||
|
||||
# Update theme fields
|
||||
self._apply_theme_updates(theme, theme_data)
|
||||
|
||||
# Commit changes
|
||||
db.commit()
|
||||
db.refresh(theme)
|
||||
|
||||
self.logger.info(f"Theme updated successfully for vendor {vendor_code}")
|
||||
return theme
|
||||
|
||||
except (VendorNotFoundException, ThemeValidationException):
|
||||
# Re-raise custom exceptions
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
self.logger.error(f"Failed to update theme for vendor {vendor_code}: {e}")
|
||||
raise ThemeOperationException(
|
||||
operation="update",
|
||||
vendor_code=vendor_code,
|
||||
reason=str(e)
|
||||
)
|
||||
|
||||
def _apply_theme_updates(
|
||||
self,
|
||||
theme: VendorTheme,
|
||||
theme_data: VendorThemeUpdate
|
||||
) -> None:
|
||||
"""
|
||||
Apply theme updates to theme object.
|
||||
|
||||
Args:
|
||||
theme: VendorTheme object to update
|
||||
theme_data: Theme update data
|
||||
"""
|
||||
# Update theme name
|
||||
if theme_data.theme_name:
|
||||
theme.theme_name = theme_data.theme_name
|
||||
|
||||
# Update colors
|
||||
if theme_data.colors:
|
||||
theme.colors = theme_data.colors
|
||||
|
||||
# Update fonts
|
||||
if theme_data.fonts:
|
||||
if theme_data.fonts.get('heading'):
|
||||
theme.font_family_heading = theme_data.fonts['heading']
|
||||
if theme_data.fonts.get('body'):
|
||||
theme.font_family_body = theme_data.fonts['body']
|
||||
|
||||
# Update branding
|
||||
if theme_data.branding:
|
||||
if theme_data.branding.get('logo') is not None:
|
||||
theme.logo_url = theme_data.branding['logo']
|
||||
if theme_data.branding.get('logo_dark') is not None:
|
||||
theme.logo_dark_url = theme_data.branding['logo_dark']
|
||||
if theme_data.branding.get('favicon') is not None:
|
||||
theme.favicon_url = theme_data.branding['favicon']
|
||||
if theme_data.branding.get('banner') is not None:
|
||||
theme.banner_url = theme_data.branding['banner']
|
||||
|
||||
# Update layout
|
||||
if theme_data.layout:
|
||||
if theme_data.layout.get('style'):
|
||||
theme.layout_style = theme_data.layout['style']
|
||||
if theme_data.layout.get('header'):
|
||||
theme.header_style = theme_data.layout['header']
|
||||
if theme_data.layout.get('product_card'):
|
||||
theme.product_card_style = theme_data.layout['product_card']
|
||||
|
||||
# Update custom CSS
|
||||
if theme_data.custom_css is not None:
|
||||
theme.custom_css = theme_data.custom_css
|
||||
|
||||
# Update social links
|
||||
if theme_data.social_links:
|
||||
theme.social_links = theme_data.social_links
|
||||
|
||||
# ============================================================================
|
||||
# PRESET OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
def apply_theme_preset(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_code: str,
|
||||
preset_name: str
|
||||
) -> VendorTheme:
|
||||
"""
|
||||
Apply a theme preset to vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code
|
||||
preset_name: Name of preset to apply
|
||||
|
||||
Returns:
|
||||
Updated VendorTheme object
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
ThemePresetNotFoundException: If preset not found
|
||||
ThemeOperationException: If application fails
|
||||
"""
|
||||
self.logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}")
|
||||
|
||||
try:
|
||||
# Validate preset name
|
||||
if preset_name not in THEME_PRESETS:
|
||||
available = get_available_presets()
|
||||
raise ThemePresetNotFoundException(preset_name, available)
|
||||
|
||||
# Verify vendor exists
|
||||
vendor = self._get_vendor_by_code(db, vendor_code)
|
||||
|
||||
# Get or create theme
|
||||
theme = db.query(VendorTheme).filter(
|
||||
VendorTheme.vendor_id == vendor.id
|
||||
).first()
|
||||
|
||||
if not theme:
|
||||
self.logger.info(f"Creating new theme for vendor {vendor_code}")
|
||||
theme = VendorTheme(vendor_id=vendor.id)
|
||||
db.add(theme)
|
||||
|
||||
# Apply preset using helper function
|
||||
apply_preset(theme, preset_name)
|
||||
|
||||
# Commit changes
|
||||
db.commit()
|
||||
db.refresh(theme)
|
||||
|
||||
self.logger.info(f"Preset '{preset_name}' applied successfully to vendor {vendor_code}")
|
||||
return theme
|
||||
|
||||
except (VendorNotFoundException, ThemePresetNotFoundException):
|
||||
# Re-raise custom exceptions
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
self.logger.error(f"Failed to apply preset to vendor {vendor_code}: {e}")
|
||||
raise ThemeOperationException(
|
||||
operation="apply_preset",
|
||||
vendor_code=vendor_code,
|
||||
reason=str(e)
|
||||
)
|
||||
|
||||
def get_available_presets(self) -> List[ThemePresetPreview]:
|
||||
"""
|
||||
Get list of available theme presets.
|
||||
|
||||
Returns:
|
||||
List of preset preview objects
|
||||
"""
|
||||
self.logger.debug("Getting available presets")
|
||||
|
||||
preset_names = get_available_presets()
|
||||
presets = []
|
||||
|
||||
for name in preset_names:
|
||||
preview = get_preset_preview(name)
|
||||
presets.append(preview)
|
||||
|
||||
return presets
|
||||
|
||||
# ============================================================================
|
||||
# THEME DELETION
|
||||
# ============================================================================
|
||||
|
||||
def delete_theme(self, db: Session, vendor_code: str) -> Dict:
|
||||
"""
|
||||
Delete custom theme for vendor (reverts to default).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_code: Vendor code
|
||||
|
||||
Returns:
|
||||
Success message dictionary
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor not found
|
||||
VendorThemeNotFoundException: If no custom theme exists
|
||||
ThemeOperationException: If deletion fails
|
||||
"""
|
||||
self.logger.info(f"Deleting theme for vendor: {vendor_code}")
|
||||
|
||||
try:
|
||||
# Verify vendor exists
|
||||
vendor = self._get_vendor_by_code(db, vendor_code)
|
||||
|
||||
# Get theme
|
||||
theme = db.query(VendorTheme).filter(
|
||||
VendorTheme.vendor_id == vendor.id
|
||||
).first()
|
||||
|
||||
if not theme:
|
||||
raise VendorThemeNotFoundException(vendor_code)
|
||||
|
||||
# Delete theme
|
||||
db.delete(theme)
|
||||
db.commit()
|
||||
|
||||
self.logger.info(f"Theme deleted for vendor {vendor_code}")
|
||||
return {
|
||||
"message": "Theme deleted successfully. Vendor will use default theme."
|
||||
}
|
||||
|
||||
except (VendorNotFoundException, VendorThemeNotFoundException):
|
||||
# Re-raise custom exceptions
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
self.logger.error(f"Failed to delete theme for vendor {vendor_code}: {e}")
|
||||
raise ThemeOperationException(
|
||||
operation="delete",
|
||||
vendor_code=vendor_code,
|
||||
reason=str(e)
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# VALIDATION
|
||||
# ============================================================================
|
||||
|
||||
def _validate_theme_data(self, theme_data: VendorThemeUpdate) -> None:
|
||||
"""
|
||||
Validate theme data before applying.
|
||||
|
||||
Args:
|
||||
theme_data: Theme update data
|
||||
|
||||
Raises:
|
||||
ThemeValidationException: If validation fails
|
||||
InvalidColorFormatException: If color format invalid
|
||||
InvalidFontFamilyException: If font family invalid
|
||||
"""
|
||||
# Validate colors
|
||||
if theme_data.colors:
|
||||
for color_key, color_value in theme_data.colors.items():
|
||||
if not self._is_valid_color(color_value):
|
||||
raise InvalidColorFormatException(color_value, color_key)
|
||||
|
||||
# Validate fonts
|
||||
if theme_data.fonts:
|
||||
for font_key, font_value in theme_data.fonts.items():
|
||||
if not self._is_valid_font(font_value):
|
||||
raise InvalidFontFamilyException(font_value, font_key)
|
||||
|
||||
# Validate layout values
|
||||
if theme_data.layout:
|
||||
valid_layouts = {
|
||||
'style': ['grid', 'list', 'masonry'],
|
||||
'header': ['fixed', 'static', 'transparent'],
|
||||
'product_card': ['modern', 'classic', 'minimal']
|
||||
}
|
||||
|
||||
for layout_key, layout_value in theme_data.layout.items():
|
||||
if layout_key in valid_layouts:
|
||||
if layout_value not in valid_layouts[layout_key]:
|
||||
raise ThemeValidationException(
|
||||
message=f"Invalid {layout_key} value: {layout_value}",
|
||||
field=layout_key,
|
||||
validation_errors={
|
||||
layout_key: f"Must be one of: {', '.join(valid_layouts[layout_key])}"
|
||||
}
|
||||
)
|
||||
|
||||
def _is_valid_color(self, color: str) -> bool:
|
||||
"""
|
||||
Validate color format (hex color).
|
||||
|
||||
Args:
|
||||
color: Color string to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not color:
|
||||
return False
|
||||
|
||||
# Check for hex color format (#RGB or #RRGGBB)
|
||||
hex_pattern = r'^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'
|
||||
return bool(re.match(hex_pattern, color))
|
||||
|
||||
def _is_valid_font(self, font: str) -> bool:
|
||||
"""
|
||||
Validate font family format.
|
||||
|
||||
Args:
|
||||
font: Font family string to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not font or len(font) < 3:
|
||||
return False
|
||||
|
||||
# Basic validation - font should not be empty and should be reasonable length
|
||||
return len(font) <= 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SERVICE INSTANCE
|
||||
# ============================================================================
|
||||
|
||||
vendor_theme_service = VendorThemeService()
|
||||
@@ -22,11 +22,11 @@
|
||||
<body x-cloak>
|
||||
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
|
||||
<!-- Sidebar (server-side included) -->
|
||||
{% include 'partials/sidebar.html' %}
|
||||
{% include 'admin/partials/sidebar.html' %}
|
||||
|
||||
<div class="flex flex-col flex-1 w-full">
|
||||
<!-- Header (server-side included) -->
|
||||
{% include 'partials/header.html' %}
|
||||
{% include 'admin/partials/header.html' %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="h-full overflow-y-auto">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- app/templates/partials/header.html -->
|
||||
<!-- app/templates/admin/partials/header.html -->
|
||||
<!-- Top header bar with search, theme toggle, notifications, profile -->
|
||||
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
|
||||
<div class="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{# app/templates/partials/sidebar.html #}
|
||||
{# app/templates/admin/partials/sidebar.html #}
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
|
||||
<div class="py-4 text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -1,290 +1,459 @@
|
||||
{# app/templates/admin/vendor-theme.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Vendor Theme - {{ vendor_code }}{% endblock %}
|
||||
{% block title %}Theme Editor - {{ vendor_code }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}vendorThemeData(){% endblock %}
|
||||
{# ✅ CRITICAL: Binds to adminVendorTheme() function in vendor-theme.js #}
|
||||
{% block alpine_data %}adminVendorTheme(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-6 mx-auto grid">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Vendor Theme
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Customize appearance for <span x-text="vendor?.name"></span>
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Theme Editor
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Customize appearance for <span x-text="vendor?.name || '...'"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a :href="`/admin/vendors/${vendorCode}`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back to Vendor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600 animate-spin')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading theme...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
|
||||
<p x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
|
||||
|
||||
<!-- Theme Configuration Form (2 columns) -->
|
||||
<div class="md:col-span-2 space-y-6">
|
||||
|
||||
<!-- Theme Presets -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('palette', 'inline w-5 h-5 mr-2')"></span>
|
||||
Choose a Preset
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Start with a pre-designed theme, then customize it to match your brand.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a :href="`/admin/vendors/${vendorCode}`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back to Vendor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600 animate-spin')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading theme...</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
|
||||
|
||||
<!-- Theme Configuration Form (2 columns) -->
|
||||
<div class="md:col-span-2">
|
||||
<div class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Theme Configuration
|
||||
</h3>
|
||||
|
||||
<!-- Theme Preset Selector -->
|
||||
<div class="mb-6 p-4 bg-purple-50 dark:bg-purple-900 dark:bg-opacity-20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<h4 class="text-sm font-semibold text-purple-800 dark:text-purple-200 mb-2">
|
||||
Quick Start: Choose a Preset
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<button @click="applyPreset('modern')"
|
||||
class="px-3 py-2 text-sm font-medium text-purple-700 bg-white border border-purple-300 rounded-lg hover:bg-purple-50 dark:bg-gray-800 dark:text-purple-300 dark:border-purple-700">
|
||||
Modern
|
||||
</button>
|
||||
<button @click="applyPreset('classic')"
|
||||
class="px-3 py-2 text-sm font-medium text-blue-700 bg-white border border-blue-300 rounded-lg hover:bg-blue-50 dark:bg-gray-800 dark:text-blue-300 dark:border-blue-700">
|
||||
Classic
|
||||
</button>
|
||||
<button @click="applyPreset('minimal')"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700">
|
||||
Minimal
|
||||
</button>
|
||||
<button @click="applyPreset('vibrant')"
|
||||
class="px-3 py-2 text-sm font-medium text-orange-700 bg-white border border-orange-300 rounded-lg hover:bg-orange-50 dark:bg-gray-800 dark:text-orange-300 dark:border-orange-700">
|
||||
Vibrant
|
||||
</button>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<!-- Default Preset -->
|
||||
<button @click="applyPreset('default')"
|
||||
:disabled="saving"
|
||||
:class="themeData.theme_name === 'default' ? 'ring-2 ring-purple-500' : ''"
|
||||
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-purple-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||
<div class="w-4 h-4 rounded bg-indigo-500"></div>
|
||||
<div class="w-4 h-4 rounded bg-purple-500"></div>
|
||||
<div class="w-4 h-4 rounded bg-pink-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Default</p>
|
||||
</button>
|
||||
|
||||
<!-- Colors Section -->
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Colors</h4>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Primary Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Primary Color</span>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.primary"
|
||||
class="h-10 w-20 border border-gray-300 rounded dark:border-gray-600 cursor-pointer">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.primary"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Secondary Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Secondary Color</span>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.secondary"
|
||||
class="h-10 w-20 border border-gray-300 rounded dark:border-gray-600 cursor-pointer">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.secondary"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Accent Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Accent Color</span>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.accent"
|
||||
class="h-10 w-20 border border-gray-300 rounded dark:border-gray-600 cursor-pointer">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.accent"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input">
|
||||
</div>
|
||||
</label>
|
||||
<!-- Modern Preset -->
|
||||
<button @click="applyPreset('modern')"
|
||||
:disabled="saving"
|
||||
:class="themeData.theme_name === 'modern' ? 'ring-2 ring-purple-500' : ''"
|
||||
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-purple-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||
<div class="w-4 h-4 rounded bg-indigo-500"></div>
|
||||
<div class="w-4 h-4 rounded bg-purple-600"></div>
|
||||
<div class="w-4 h-4 rounded bg-pink-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Modern</p>
|
||||
</button>
|
||||
|
||||
<!-- Typography Section -->
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Typography</h4>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Heading Font -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Heading Font</span>
|
||||
<select x-model="themeData.fonts.heading"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
|
||||
<option value="Inter, sans-serif">Inter</option>
|
||||
<option value="Roboto, sans-serif">Roboto</option>
|
||||
<option value="Poppins, sans-serif">Poppins</option>
|
||||
<option value="Playfair Display, serif">Playfair Display</option>
|
||||
<option value="Merriweather, serif">Merriweather</option>
|
||||
<option value="Georgia, serif">Georgia</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Body Font -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Body Font</span>
|
||||
<select x-model="themeData.fonts.body"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
|
||||
<option value="Inter, sans-serif">Inter</option>
|
||||
<option value="Open Sans, sans-serif">Open Sans</option>
|
||||
<option value="Lato, sans-serif">Lato</option>
|
||||
<option value="Source Sans Pro, sans-serif">Source Sans Pro</option>
|
||||
<option value="Arial, sans-serif">Arial</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Classic Preset -->
|
||||
<button @click="applyPreset('classic')"
|
||||
:disabled="saving"
|
||||
:class="themeData.theme_name === 'classic' ? 'ring-2 ring-purple-500' : ''"
|
||||
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-blue-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||
<div class="w-4 h-4 rounded bg-blue-800"></div>
|
||||
<div class="w-4 h-4 rounded bg-purple-700"></div>
|
||||
<div class="w-4 h-4 rounded bg-red-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Classic</p>
|
||||
</button>
|
||||
|
||||
<!-- Layout Section -->
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Layout</h4>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Product Layout Style -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Product Layout</span>
|
||||
<select x-model="themeData.layout.style"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
|
||||
<option value="grid">Grid</option>
|
||||
<option value="list">List</option>
|
||||
<option value="masonry">Masonry</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Header Style -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Header Style</span>
|
||||
<select x-model="themeData.layout.header"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select">
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="transparent">Transparent</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Minimal Preset -->
|
||||
<button @click="applyPreset('minimal')"
|
||||
:disabled="saving"
|
||||
:class="themeData.theme_name === 'minimal' ? 'ring-2 ring-purple-500' : ''"
|
||||
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-gray-400 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||
<div class="w-4 h-4 rounded bg-black"></div>
|
||||
<div class="w-4 h-4 rounded bg-gray-600"></div>
|
||||
<div class="w-4 h-4 rounded bg-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Minimal</p>
|
||||
</button>
|
||||
|
||||
<!-- Custom CSS Section -->
|
||||
<div class="mb-6">
|
||||
<h4 class="mb-4 text-md font-semibold text-gray-700 dark:text-gray-200">Advanced</h4>
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Custom CSS</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
Advanced: Add custom CSS rules (use with caution)
|
||||
</p>
|
||||
<textarea x-model="themeData.custom_css"
|
||||
rows="6"
|
||||
placeholder=".my-custom-class { color: red; }"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-textarea"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Vibrant Preset -->
|
||||
<button @click="applyPreset('vibrant')"
|
||||
:disabled="saving"
|
||||
:class="themeData.theme_name === 'vibrant' ? 'ring-2 ring-purple-500' : ''"
|
||||
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-orange-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||
<div class="w-4 h-4 rounded bg-orange-500"></div>
|
||||
<div class="w-4 h-4 rounded bg-red-500"></div>
|
||||
<div class="w-4 h-4 rounded bg-purple-600"></div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Vibrant</p>
|
||||
</button>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-between items-center">
|
||||
<button @click="resetToDefault()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-red-700 transition-colors duration-150 bg-white border border-red-300 rounded-lg hover:bg-red-50 focus:outline-none disabled:opacity-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-600">
|
||||
Reset to Default
|
||||
</button>
|
||||
<button @click="saveTheme()"
|
||||
:disabled="saving"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
|
||||
<span x-show="!saving">Save Theme</span>
|
||||
<span x-show="saving" class="flex items-center">
|
||||
<span x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Elegant Preset -->
|
||||
<button @click="applyPreset('elegant')"
|
||||
:disabled="saving"
|
||||
:class="themeData.theme_name === 'elegant' ? 'ring-2 ring-purple-500' : ''"
|
||||
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-gray-400 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||
<div class="w-4 h-4 rounded bg-gray-500"></div>
|
||||
<div class="w-4 h-4 rounded bg-gray-700"></div>
|
||||
<div class="w-4 h-4 rounded bg-amber-600"></div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Elegant</p>
|
||||
</button>
|
||||
|
||||
<!-- Nature Preset -->
|
||||
<button @click="applyPreset('nature')"
|
||||
:disabled="saving"
|
||||
:class="themeData.theme_name === 'nature' ? 'ring-2 ring-purple-500' : ''"
|
||||
class="p-3 text-sm font-medium bg-white border-2 border-gray-200 rounded-lg hover:border-green-300 transition-all disabled:opacity-50 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex items-center justify-center space-x-2 mb-2">
|
||||
<div class="w-4 h-4 rounded bg-green-600"></div>
|
||||
<div class="w-4 h-4 rounded bg-emerald-500"></div>
|
||||
<div class="w-4 h-4 rounded bg-amber-500"></div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Nature</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel (1 column) -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 sticky top-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Preview
|
||||
</h3>
|
||||
<!-- Colors Section -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('color-swatch', 'inline w-5 h-5 mr-2')"></span>
|
||||
Colors
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Primary Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Primary Color</span>
|
||||
<p class="text-xs text-gray-500 mb-2">Main brand color for buttons and links</p>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.primary"
|
||||
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.primary"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Theme Preview -->
|
||||
<div class="space-y-4">
|
||||
<!-- Colors Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">COLORS</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="text-center">
|
||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
:style="`background-color: ${themeData.colors.primary}`"></div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Primary</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
:style="`background-color: ${themeData.colors.secondary}`"></div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Secondary</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
:style="`background-color: ${themeData.colors.accent}`"></div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Accent</p>
|
||||
</div>
|
||||
<!-- Secondary Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Secondary Color</span>
|
||||
<p class="text-xs text-gray-500 mb-2">Supporting color for accents</p>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.secondary"
|
||||
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.secondary"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Accent Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Accent Color</span>
|
||||
<p class="text-xs text-gray-500 mb-2">Call-to-action and highlights</p>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.accent"
|
||||
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.accent"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Background Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Background Color</span>
|
||||
<p class="text-xs text-gray-500 mb-2">Page background</p>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.background"
|
||||
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.background"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Text Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Text Color</span>
|
||||
<p class="text-xs text-gray-500 mb-2">Primary text color</p>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.text"
|
||||
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.text"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Border Color -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Border Color</span>
|
||||
<p class="text-xs text-gray-500 mb-2">Borders and dividers</p>
|
||||
<div class="flex items-center mt-1 space-x-2">
|
||||
<input type="color"
|
||||
x-model="themeData.colors.border"
|
||||
class="h-10 w-20 border border-gray-300 rounded cursor-pointer dark:border-gray-600">
|
||||
<input type="text"
|
||||
x-model="themeData.colors.border"
|
||||
class="block w-full text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input rounded">
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typography Section -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('document-text', 'inline w-5 h-5 mr-2')"></span>
|
||||
Typography
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Heading Font -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Heading Font</span>
|
||||
<p class="text-xs text-gray-500 mb-2">For titles and headings</p>
|
||||
<select x-model="themeData.fonts.heading"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
|
||||
<option value="Inter, sans-serif">Inter (Modern)</option>
|
||||
<option value="Roboto, sans-serif">Roboto (Clean)</option>
|
||||
<option value="Poppins, sans-serif">Poppins (Friendly)</option>
|
||||
<option value="Playfair Display, serif">Playfair Display (Elegant)</option>
|
||||
<option value="Merriweather, serif">Merriweather (Classic)</option>
|
||||
<option value="Georgia, serif">Georgia (Traditional)</option>
|
||||
<option value="Helvetica, sans-serif">Helvetica (Minimal)</option>
|
||||
<option value="Montserrat, sans-serif">Montserrat (Bold)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Body Font -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Body Font</span>
|
||||
<p class="text-xs text-gray-500 mb-2">For body text and content</p>
|
||||
<select x-model="themeData.fonts.body"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
|
||||
<option value="Inter, sans-serif">Inter (Modern)</option>
|
||||
<option value="Roboto, sans-serif">Roboto (Clean)</option>
|
||||
<option value="Open Sans, sans-serif">Open Sans (Readable)</option>
|
||||
<option value="Lato, sans-serif">Lato (Friendly)</option>
|
||||
<option value="Arial, sans-serif">Arial (Universal)</option>
|
||||
<option value="Georgia, serif">Georgia (Traditional)</option>
|
||||
<option value="Helvetica, sans-serif">Helvetica (Minimal)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Section -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('view-grid', 'inline w-5 h-5 mr-2')"></span>
|
||||
Layout
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- Product Layout Style -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Product Layout</span>
|
||||
<select x-model="themeData.layout.style"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
|
||||
<option value="grid">Grid</option>
|
||||
<option value="list">List</option>
|
||||
<option value="masonry">Masonry</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Header Style -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Header Style</span>
|
||||
<select x-model="themeData.layout.header"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="transparent">Transparent</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Product Card Style -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400 font-medium">Product Card</span>
|
||||
<select x-model="themeData.layout.product_card"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-select rounded">
|
||||
<option value="modern">Modern</option>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom CSS Section -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('code', 'inline w-5 h-5 mr-2')"></span>
|
||||
Advanced: Custom CSS
|
||||
</h3>
|
||||
<label class="block text-sm">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Add custom CSS rules for advanced styling (use with caution)
|
||||
</p>
|
||||
<textarea x-model="themeData.custom_css"
|
||||
rows="6"
|
||||
placeholder=".my-custom-class {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}"
|
||||
class="block w-full mt-1 text-sm font-mono dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-textarea rounded"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-between items-center">
|
||||
<button @click="resetToDefault()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-red-700 transition-colors duration-150 bg-white border border-red-300 rounded-lg hover:bg-red-50 focus:outline-none disabled:opacity-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-600">
|
||||
<span x-html="$icon('refresh', 'inline w-4 h-4 mr-2')"></span>
|
||||
Reset to Default
|
||||
</button>
|
||||
<button @click="saveTheme()"
|
||||
:disabled="saving"
|
||||
class="flex items-center px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
|
||||
<span x-show="!saving">
|
||||
<span x-html="$icon('save', 'inline w-4 h-4 mr-2')"></span>
|
||||
Save Theme
|
||||
</span>
|
||||
<span x-show="saving" class="flex items-center">
|
||||
<span x-html="$icon('spinner', 'inline w-4 h-4 mr-2 animate-spin')"></span>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel (1 column) -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 sticky top-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('eye', 'inline w-5 h-5 mr-2')"></span>
|
||||
Preview
|
||||
</h3>
|
||||
|
||||
<!-- Theme Preview -->
|
||||
<div class="space-y-4">
|
||||
<!-- Current Theme Name -->
|
||||
<div class="p-3 bg-purple-50 dark:bg-purple-900 dark:bg-opacity-20 rounded-lg">
|
||||
<p class="text-xs font-semibold text-purple-800 dark:text-purple-200 mb-1">ACTIVE THEME</p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="themeData.theme_name"></p>
|
||||
</div>
|
||||
|
||||
<!-- Colors Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">COLORS</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="text-center">
|
||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
|
||||
:style="`background-color: ${themeData.colors.primary}`"></div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Primary</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
|
||||
:style="`background-color: ${themeData.colors.secondary}`"></div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Secondary</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h-12 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
|
||||
:style="`background-color: ${themeData.colors.accent}`"></div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Accent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typography Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">TYPOGRAPHY</p>
|
||||
<div class="space-y-2">
|
||||
<p class="text-lg" :style="`font-family: ${themeData.fonts.heading}`">
|
||||
Heading Font
|
||||
</p>
|
||||
<p class="text-sm" :style="`font-family: ${themeData.fonts.body}`">
|
||||
Body text font example
|
||||
</p>
|
||||
</div>
|
||||
<!-- Typography Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">TYPOGRAPHY</p>
|
||||
<div class="space-y-2 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-lg font-bold" :style="`font-family: ${themeData.fonts.heading}`">
|
||||
Heading Font
|
||||
</p>
|
||||
<p class="text-sm" :style="`font-family: ${themeData.fonts.body}`">
|
||||
This is body text font example. It will be used for paragraphs and descriptions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">BUTTONS</p>
|
||||
<button class="px-4 py-2 text-sm font-medium text-white rounded-lg w-full"
|
||||
:style="`background-color: ${themeData.colors.primary}`">
|
||||
Primary Button
|
||||
</button>
|
||||
</div>
|
||||
<!-- Button Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">BUTTONS</p>
|
||||
<button class="px-4 py-2 text-sm font-medium text-white rounded-lg w-full shadow-sm"
|
||||
:style="`background-color: ${themeData.colors.primary}`">
|
||||
Primary Button
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Layout Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">LAYOUT</p>
|
||||
<div class="text-xs space-y-1">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-semibold">Product Layout:</span>
|
||||
<span class="capitalize" x-text="themeData.layout.style"></span>
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-semibold">Header:</span>
|
||||
<span class="capitalize" x-text="themeData.layout.header"></span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Layout Preview -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2">LAYOUT</p>
|
||||
<div class="text-xs space-y-1 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-semibold">Product Layout:</span>
|
||||
<span class="capitalize" x-text="themeData.layout.style"></span>
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-semibold">Header:</span>
|
||||
<span class="capitalize" x-text="themeData.layout.header"></span>
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-semibold">Product Card:</span>
|
||||
<span class="capitalize" x-text="themeData.layout.product_card"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Link -->
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a :href="`http://${vendor?.subdomain}.localhost:8000`"
|
||||
target="_blank"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-300 rounded-lg hover:bg-purple-100 dark:bg-purple-900 dark:bg-opacity-20 dark:text-purple-300 dark:border-purple-700">
|
||||
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
|
||||
View Live Shop
|
||||
</a>
|
||||
</div>
|
||||
<!-- Preview Link -->
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a :href="`http://${vendor?.subdomain}.localhost:8000`"
|
||||
target="_blank"
|
||||
class="flex items-center justify-center px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-300 rounded-lg hover:bg-purple-100 dark:bg-purple-900 dark:bg-opacity-20 dark:text-purple-300 dark:border-purple-700 transition-colors">
|
||||
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
|
||||
View Live Shop
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user