refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -1,11 +1,15 @@
# app/modules/cms/exceptions.py
"""
CMS Module Exceptions
CMS module exceptions.
These exceptions are raised by the CMS module service layer
and converted to HTTP responses by the global exception handler.
This module provides exception classes for CMS operations including:
- Content page management
- Media file handling
- Vendor theme customization
"""
from typing import Any
from app.exceptions.base import (
AuthorizationException,
BusinessLogicException,
@@ -14,6 +18,39 @@ from app.exceptions.base import (
ValidationException,
)
__all__ = [
# Content page exceptions
"ContentPageNotFoundException",
"ContentPageAlreadyExistsException",
"ContentPageSlugReservedException",
"ContentPageNotPublishedException",
"UnauthorizedContentPageAccessException",
"VendorNotAssociatedException",
"ContentPageValidationException",
# Media exceptions
"MediaNotFoundException",
"MediaUploadException",
"MediaValidationException",
"UnsupportedMediaTypeException",
"MediaFileTooLargeException",
"MediaOptimizationException",
"MediaDeleteException",
# Theme exceptions
"VendorThemeNotFoundException",
"InvalidThemeDataException",
"ThemePresetNotFoundException",
"ThemeValidationException",
"ThemePresetAlreadyAppliedException",
"InvalidColorFormatException",
"InvalidFontFamilyException",
"ThemeOperationException",
]
# =============================================================================
# Content Page Exceptions
# =============================================================================
class ContentPageNotFoundException(ResourceNotFoundException):
"""Raised when a content page is not found."""
@@ -25,8 +62,9 @@ class ContentPageNotFoundException(ResourceNotFoundException):
message = "Content page not found"
super().__init__(
message=message,
resource_type="content_page",
resource_type="ContentPage",
identifier=str(identifier) if identifier else "unknown",
error_code="CONTENT_PAGE_NOT_FOUND",
)
@@ -38,7 +76,11 @@ class ContentPageAlreadyExistsException(ConflictException):
message = f"Content page with slug '{slug}' already exists for this vendor"
else:
message = f"Platform content page with slug '{slug}' already exists"
super().__init__(message=message)
super().__init__(
message=message,
error_code="CONTENT_PAGE_ALREADY_EXISTS",
details={"slug": slug, "vendor_id": vendor_id} if vendor_id else {"slug": slug},
)
class ContentPageSlugReservedException(ValidationException):
@@ -48,15 +90,20 @@ class ContentPageSlugReservedException(ValidationException):
super().__init__(
message=f"Content page slug '{slug}' is reserved",
field="slug",
value=slug,
details={"slug": slug},
)
self.error_code = "CONTENT_PAGE_SLUG_RESERVED"
class ContentPageNotPublishedException(BusinessLogicException):
"""Raised when trying to access an unpublished content page."""
def __init__(self, slug: str):
super().__init__(message=f"Content page '{slug}' is not published")
super().__init__(
message=f"Content page '{slug}' is not published",
error_code="CONTENT_PAGE_NOT_PUBLISHED",
details={"slug": slug},
)
class UnauthorizedContentPageAccessException(AuthorizationException):
@@ -85,26 +132,225 @@ class ContentPageValidationException(ValidationException):
"""Raised when content page data validation fails."""
def __init__(self, field: str, message: str, value: str | None = None):
super().__init__(message=message, field=field, value=value)
details = {}
if value:
details["value"] = value
super().__init__(message=message, field=field, details=details if details else None)
self.error_code = "CONTENT_PAGE_VALIDATION_FAILED"
# =============================================================================
# Media Exceptions
# =============================================================================
class MediaNotFoundException(ResourceNotFoundException):
"""Raised when a media item is not found."""
"""Raised when a media file is not found."""
def __init__(self, identifier: str | int | None = None):
if identifier:
message = f"Media item not found: {identifier}"
def __init__(self, media_id: int | str | None = None):
if media_id:
message = f"Media file '{media_id}' not found"
else:
message = "Media item not found"
message = "Media file not found"
super().__init__(
resource_type="MediaFile",
identifier=str(media_id) if media_id else "unknown",
message=message,
resource_type="media",
identifier=str(identifier) if identifier else "unknown",
error_code="MEDIA_NOT_FOUND",
)
class MediaUploadException(BusinessLogicException):
"""Raised when a media upload fails."""
"""Raised when media upload fails."""
def __init__(self, reason: str):
super().__init__(message=f"Media upload failed: {reason}")
def __init__(self, message: str = "Media upload failed", details: dict[str, Any] | None = None):
super().__init__(
message=message,
error_code="MEDIA_UPLOAD_FAILED",
details=details,
)
class MediaValidationException(ValidationException):
"""Raised when media validation fails (file type, size, etc.)."""
def __init__(
self,
message: str = "Media validation failed",
field: str | None = None,
details: dict[str, Any] | None = None,
):
super().__init__(message=message, field=field, details=details)
self.error_code = "MEDIA_VALIDATION_FAILED"
class UnsupportedMediaTypeException(ValidationException):
"""Raised when media file type is not supported."""
def __init__(self, file_type: str, allowed_types: list[str] | None = None):
details = {"file_type": file_type}
if allowed_types:
details["allowed_types"] = allowed_types
super().__init__(
message=f"Unsupported media type: {file_type}",
field="file",
details=details,
)
self.error_code = "UNSUPPORTED_MEDIA_TYPE"
class MediaFileTooLargeException(ValidationException):
"""Raised when media file exceeds size limit."""
def __init__(self, file_size: int, max_size: int, media_type: str = "file"):
super().__init__(
message=f"File size ({file_size} bytes) exceeds maximum allowed ({max_size} bytes) for {media_type}",
field="file",
details={
"file_size": file_size,
"max_size": max_size,
"media_type": media_type,
},
)
self.error_code = "MEDIA_FILE_TOO_LARGE"
class MediaOptimizationException(BusinessLogicException):
"""Raised when media optimization fails."""
def __init__(self, message: str = "Only images can be optimized"):
super().__init__(
message=message,
error_code="MEDIA_OPTIMIZATION_FAILED",
)
class MediaDeleteException(BusinessLogicException):
"""Raised when media deletion fails."""
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(
message=message,
error_code="MEDIA_DELETE_FAILED",
details=details,
)
# =============================================================================
# Theme Exceptions
# =============================================================================
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: str | None = None,
details: dict[str, Any] | None = 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: list | None = None):
super().__init__(
resource_type="ThemePreset",
identifier=preset_name,
message=f"Theme preset '{preset_name}' not found",
error_code="THEME_PRESET_NOT_FOUND",
)
if available_presets:
self.details["available_presets"] = available_presets
class ThemeValidationException(ValidationException):
"""Raised when theme validation fails."""
def __init__(
self,
message: str = "Theme validation failed",
field: str | None = None,
validation_errors: dict[str, str] | None = None,
):
details = {}
if validation_errors:
details["validation_errors"] = validation_errors
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "THEME_VALIDATION_FAILED"
class ThemePresetAlreadyAppliedException(BusinessLogicException):
"""Raised when trying to apply the same preset that's already active."""
def __init__(self, preset_name: str, vendor_code: str):
super().__init__(
message=f"Preset '{preset_name}' is already applied to vendor '{vendor_code}'",
error_code="THEME_PRESET_ALREADY_APPLIED",
details={"preset_name": preset_name, "vendor_code": vendor_code},
)
class InvalidColorFormatException(ValidationException):
"""Raised when color format is invalid."""
def __init__(self, color_value: str, field: str):
super().__init__(
message=f"Invalid color format: {color_value}",
field=field,
details={"color_value": color_value},
)
self.error_code = "INVALID_COLOR_FORMAT"
class InvalidFontFamilyException(ValidationException):
"""Raised when font family is invalid."""
def __init__(self, font_value: str, field: str):
super().__init__(
message=f"Invalid font family: {font_value}",
field=field,
details={"font_value": font_value},
)
self.error_code = "INVALID_FONT_FAMILY"
class ThemeOperationException(BusinessLogicException):
"""Raised when theme operation fails."""
def __init__(self, operation: str, vendor_code: str, reason: str):
super().__init__(
message=f"Theme operation '{operation}' failed for vendor '{vendor_code}': {reason}",
error_code="THEME_OPERATION_FAILED",
details={
"operation": operation,
"vendor_code": vendor_code,
"reason": reason,
},
)

View File

@@ -1,25 +0,0 @@
# app/modules/cms/routes/admin.py
"""
CMS module admin routes.
This module wraps the existing admin content pages routes and adds
module-based access control. Routes are re-exported from the
original location with the module access dependency.
"""
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
# Import original router (direct import to avoid circular dependency)
from app.api.v1.admin.content_pages import router as original_router
# Create module-aware router
admin_router = APIRouter(
prefix="/content-pages",
dependencies=[Depends(require_module_access("cms"))],
)
# Re-export all routes from the original module with module access control
for route in original_router.routes:
admin_router.routes.append(route)

View File

@@ -1,310 +1,32 @@
# app/modules/cms/routes/api/admin.py
"""
Admin Content Pages API
CMS module admin API routes.
Platform administrators can:
- Create/edit/delete platform default content pages
- View all vendor content pages
- Override vendor content if needed
Aggregates all admin CMS routes:
- /content-pages/* - Content page management
- /images/* - Image upload and management
- /media/* - Vendor media libraries
- /vendor-themes/* - Vendor theme customization
"""
import logging
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import require_module_access
from app.api.deps import get_current_admin_api, get_db
from app.exceptions import ValidationException
from app.modules.cms.schemas import (
ContentPageCreate,
ContentPageUpdate,
ContentPageResponse,
HomepageSectionsResponse,
SectionUpdateResponse,
from .admin_content_pages import admin_content_pages_router
from .admin_images import admin_images_router
from .admin_media import admin_media_router
from .admin_vendor_themes import admin_vendor_themes_router
admin_router = APIRouter(
dependencies=[Depends(require_module_access("cms"))],
)
from app.modules.cms.services import content_page_service
from models.database.user import User
# Route configuration for auto-discovery
ROUTE_CONFIG = {
"prefix": "/content-pages",
"tags": ["admin-content-pages"],
"priority": 100, # Register last (CMS has catch-all slug routes)
}
# For backwards compatibility with existing imports
router = admin_router
router = APIRouter()
admin_router = router # Alias for discovery compatibility
logger = logging.getLogger(__name__)
# ============================================================================
# PLATFORM DEFAULT PAGES (vendor_id=NULL)
# ============================================================================
@router.get("/platform", response_model=list[ContentPageResponse])
def list_platform_pages(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all platform default content pages.
These are used as fallbacks when vendors haven't created custom pages.
"""
pages = content_page_service.list_all_platform_pages(
db, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@router.post("/platform", response_model=ContentPageResponse, status_code=201)
def create_platform_page(
page_data: ContentPageCreate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a new platform default content page.
Platform defaults are shown to all vendors who haven't created their own version.
"""
# Force vendor_id to None for platform pages
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=None, # Platform default
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# VENDOR PAGES
# ============================================================================
@router.post("/vendor", response_model=ContentPageResponse, status_code=201)
def create_vendor_page(
page_data: ContentPageCreate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
Vendor pages override platform defaults for a specific vendor.
"""
if not page_data.vendor_id:
raise ValidationException(
message="vendor_id is required for vendor pages. Use /platform for platform defaults.",
field="vendor_id",
)
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=page_data.vendor_id,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# ALL CONTENT PAGES (Platform + Vendors)
# ============================================================================
@router.get("/", response_model=list[ContentPageResponse])
def list_all_pages(
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all content pages (platform defaults and vendor overrides).
Filter by vendor_id to see specific vendor pages.
"""
pages = content_page_service.list_all_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@router.get("/{page_id}", response_model=ContentPageResponse)
def get_page(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a specific content page by ID."""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
return page.to_dict()
@router.put("/{page_id}", response_model=ContentPageResponse)
def update_page(
page_id: int,
page_data: ContentPageUpdate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a content page (platform or vendor)."""
page = content_page_service.update_page_or_raise(
db,
page_id=page_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
return page.to_dict()
@router.delete("/{page_id}", status_code=204)
def delete_page(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Delete a content page."""
content_page_service.delete_page_or_raise(db, page_id)
db.commit()
# ============================================================================
# HOMEPAGE SECTIONS MANAGEMENT
# ============================================================================
@router.get("/{page_id}/sections", response_model=HomepageSectionsResponse)
def get_page_sections(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get homepage sections for a content page.
Returns sections along with platform language settings for the editor.
"""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
# Get platform languages
platform = page.platform
supported_languages = (
platform.supported_languages if platform else ["fr", "de", "en"]
)
default_language = platform.default_language if platform else "fr"
return {
"sections": page.sections,
"supported_languages": supported_languages,
"default_language": default_language,
}
@router.put("/{page_id}/sections", response_model=SectionUpdateResponse)
def update_page_sections(
page_id: int,
sections: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update all homepage sections at once.
Expected structure:
{
"hero": { ... },
"features": { ... },
"pricing": { ... },
"cta": { ... }
}
"""
page = content_page_service.update_homepage_sections(
db,
page_id=page_id,
sections=sections,
updated_by=current_user.id,
)
db.commit()
return {
"message": "Sections updated successfully",
"sections": page.sections,
}
@router.put("/{page_id}/sections/{section_name}", response_model=SectionUpdateResponse)
def update_single_section(
page_id: int,
section_name: str,
section_data: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a single section (hero, features, pricing, or cta).
section_name must be one of: hero, features, pricing, cta
"""
if section_name not in ["hero", "features", "pricing", "cta"]:
raise ValidationException(
message=f"Invalid section name: {section_name}. Must be one of: hero, features, pricing, cta",
field="section_name",
)
page = content_page_service.update_single_section(
db,
page_id=page_id,
section_name=section_name,
section_data=section_data,
updated_by=current_user.id,
)
db.commit()
return {
"message": f"Section '{section_name}' updated successfully",
"sections": page.sections,
}
# Aggregate all CMS admin routes
admin_router.include_router(admin_content_pages_router, tags=["admin-content-pages"])
admin_router.include_router(admin_images_router, tags=["admin-images"])
admin_router.include_router(admin_media_router, tags=["admin-media"])
admin_router.include_router(admin_vendor_themes_router, tags=["admin-vendor-themes"])

View File

@@ -0,0 +1,302 @@
# app/modules/cms/routes/api/admin_content_pages.py
"""
Admin Content Pages API
Platform administrators can:
- Create/edit/delete platform default content pages
- View all vendor content pages
- Override vendor content if needed
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_db
from app.exceptions import ValidationException
from app.modules.cms.schemas import (
ContentPageCreate,
ContentPageUpdate,
ContentPageResponse,
HomepageSectionsResponse,
SectionUpdateResponse,
)
from app.modules.cms.services import content_page_service
from models.database.user import User
admin_content_pages_router = APIRouter(prefix="/content-pages")
logger = logging.getLogger(__name__)
# ============================================================================
# PLATFORM DEFAULT PAGES (vendor_id=NULL)
# ============================================================================
@admin_content_pages_router.get("/platform", response_model=list[ContentPageResponse])
def list_platform_pages(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all platform default content pages.
These are used as fallbacks when vendors haven't created custom pages.
"""
pages = content_page_service.list_all_platform_pages(
db, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@admin_content_pages_router.post("/platform", response_model=ContentPageResponse, status_code=201)
def create_platform_page(
page_data: ContentPageCreate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a new platform default content page.
Platform defaults are shown to all vendors who haven't created their own version.
"""
# Force vendor_id to None for platform pages
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=None, # Platform default
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# VENDOR PAGES
# ============================================================================
@admin_content_pages_router.post("/vendor", response_model=ContentPageResponse, status_code=201)
def create_vendor_page(
page_data: ContentPageCreate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
Vendor pages override platform defaults for a specific vendor.
"""
if not page_data.vendor_id:
raise ValidationException(
message="vendor_id is required for vendor pages. Use /platform for platform defaults.",
field="vendor_id",
)
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=page_data.vendor_id,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
# ============================================================================
# ALL CONTENT PAGES (Platform + Vendors)
# ============================================================================
@admin_content_pages_router.get("/", response_model=list[ContentPageResponse])
def list_all_pages(
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all content pages (platform defaults and vendor overrides).
Filter by vendor_id to see specific vendor pages.
"""
pages = content_page_service.list_all_pages(
db, vendor_id=vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@admin_content_pages_router.get("/{page_id}", response_model=ContentPageResponse)
def get_page(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a specific content page by ID."""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
return page.to_dict()
@admin_content_pages_router.put("/{page_id}", response_model=ContentPageResponse)
def update_page(
page_id: int,
page_data: ContentPageUpdate,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update a content page (platform or vendor)."""
page = content_page_service.update_page_or_raise(
db,
page_id=page_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
return page.to_dict()
@admin_content_pages_router.delete("/{page_id}", status_code=204)
def delete_page(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Delete a content page."""
content_page_service.delete_page_or_raise(db, page_id)
db.commit()
# ============================================================================
# HOMEPAGE SECTIONS MANAGEMENT
# ============================================================================
@admin_content_pages_router.get("/{page_id}/sections", response_model=HomepageSectionsResponse)
def get_page_sections(
page_id: int,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get homepage sections for a content page.
Returns sections along with platform language settings for the editor.
"""
page = content_page_service.get_page_by_id_or_raise(db, page_id)
# Get platform languages
platform = page.platform
supported_languages = (
platform.supported_languages if platform else ["fr", "de", "en"]
)
default_language = platform.default_language if platform else "fr"
return {
"sections": page.sections,
"supported_languages": supported_languages,
"default_language": default_language,
}
@admin_content_pages_router.put("/{page_id}/sections", response_model=SectionUpdateResponse)
def update_page_sections(
page_id: int,
sections: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update all homepage sections at once.
Expected structure:
{
"hero": { ... },
"features": { ... },
"pricing": { ... },
"cta": { ... }
}
"""
page = content_page_service.update_homepage_sections(
db,
page_id=page_id,
sections=sections,
updated_by=current_user.id,
)
db.commit()
return {
"message": "Sections updated successfully",
"sections": page.sections,
}
@admin_content_pages_router.put("/{page_id}/sections/{section_name}", response_model=SectionUpdateResponse)
def update_single_section(
page_id: int,
section_name: str,
section_data: dict,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a single section (hero, features, pricing, or cta).
section_name must be one of: hero, features, pricing, cta
"""
if section_name not in ["hero", "features", "pricing", "cta"]:
raise ValidationException(
message=f"Invalid section name: {section_name}. Must be one of: hero, features, pricing, cta",
field="section_name",
)
page = content_page_service.update_single_section(
db,
page_id=page_id,
section_name=section_name,
section_data=section_data,
updated_by=current_user.id,
)
db.commit()
return {
"message": f"Section '{section_name}' updated successfully",
"sections": page.sections,
}

View File

@@ -0,0 +1,99 @@
# app/modules/cms/routes/api/admin_images.py
"""
Admin image management endpoints.
Provides:
- Image upload with automatic processing
- Image deletion
- Storage statistics
"""
import logging
from fastapi import APIRouter, Depends, File, Form, UploadFile
from app.api.deps import get_current_admin_api
from app.modules.core.services.image_service import image_service
from models.schema.auth import UserContext
from models.schema.image import (
ImageDeleteResponse,
ImageStorageStats,
ImageUploadResponse,
)
admin_images_router = APIRouter(prefix="/images")
logger = logging.getLogger(__name__)
@admin_images_router.post("/upload", response_model=ImageUploadResponse)
async def upload_image(
file: UploadFile = File(...),
vendor_id: int = Form(...),
product_id: int | None = Form(None),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Upload and process an image.
The image will be:
- Converted to WebP format
- Resized to multiple variants (original, 800px, 200px)
- Stored in a sharded directory structure
Args:
file: Image file to upload
vendor_id: Vendor ID for the image
product_id: Optional product ID
Returns:
Image URLs and metadata
"""
# Read file content
content = await file.read()
# Delegate all validation and processing to service
result = image_service.upload_product_image(
file_content=content,
filename=file.filename or "image.jpg",
content_type=file.content_type,
vendor_id=vendor_id,
product_id=product_id,
)
logger.info(f"Image uploaded: {result['id']} for vendor {vendor_id}")
return ImageUploadResponse(success=True, image=result)
@admin_images_router.delete("/{image_hash}", response_model=ImageDeleteResponse)
async def delete_image(
image_hash: str,
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Delete an image and all its variants.
Args:
image_hash: The image ID/hash
Returns:
Deletion status
"""
deleted = image_service.delete_product_image(image_hash)
if deleted:
logger.info(f"Image deleted: {image_hash}")
return ImageDeleteResponse(success=True, message="Image deleted successfully")
else:
return ImageDeleteResponse(success=False, message="Image not found")
@admin_images_router.get("/stats", response_model=ImageStorageStats)
async def get_storage_stats(
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get image storage statistics.
Returns:
Storage metrics including file counts, sizes, and directory info
"""
stats = image_service.get_storage_stats()
return ImageStorageStats(**stats)

View File

@@ -0,0 +1,138 @@
# app/modules/cms/routes/api/admin_media.py
"""
Admin media management endpoints for vendor media libraries.
Allows admins to manage media files on behalf of vendors.
"""
import logging
from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext
from models.schema.media import (
MediaDetailResponse,
MediaItemResponse,
MediaListResponse,
MediaUploadResponse,
)
admin_media_router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__)
@admin_media_router.get("/vendors/{vendor_id}", response_model=MediaListResponse)
def get_vendor_media_library(
vendor_id: int,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
media_type: str | None = Query(None, description="image, video, document"),
folder: str | None = Query(None, description="Filter by folder"),
search: str | None = Query(None),
current_admin: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get media library for a specific vendor.
Admin can browse any vendor's media library.
"""
media_files, total = media_service.get_media_library(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
media_type=media_type,
folder=folder,
search=search,
)
return MediaListResponse(
media=[MediaItemResponse.model_validate(m) for m in media_files],
total=total,
skip=skip,
limit=limit,
)
@admin_media_router.post("/vendors/{vendor_id}/upload", response_model=MediaUploadResponse)
async def upload_vendor_media(
vendor_id: int,
file: UploadFile = File(...),
folder: str | None = Query("products", description="products, general, etc."),
current_admin: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Upload media file for a specific vendor.
Admin can upload media on behalf of any vendor.
Files are stored in vendor-specific directories.
"""
# Read file content
file_content = await file.read()
# Upload using service
media_file = await media_service.upload_file(
db=db,
vendor_id=vendor_id,
file_content=file_content,
filename=file.filename or "unnamed",
folder=folder or "products",
)
logger.info(f"Admin uploaded media for vendor {vendor_id}: {media_file.id}")
return MediaUploadResponse(
success=True,
message="File uploaded successfully",
media=MediaItemResponse.model_validate(media_file),
)
@admin_media_router.get("/vendors/{vendor_id}/{media_id}", response_model=MediaDetailResponse)
def get_vendor_media_detail(
vendor_id: int,
media_id: int,
current_admin: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get detailed info about a specific media file.
"""
media_file = media_service.get_media_by_id(db=db, media_id=media_id)
# Verify media belongs to the specified vendor
if media_file.vendor_id != vendor_id:
from app.modules.cms.exceptions import MediaNotFoundException
raise MediaNotFoundException(media_id)
return MediaDetailResponse.model_validate(media_file)
@admin_media_router.delete("/vendors/{vendor_id}/{media_id}")
def delete_vendor_media(
vendor_id: int,
media_id: int,
current_admin: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Delete a media file for a vendor.
"""
media_file = media_service.get_media_by_id(db=db, media_id=media_id)
# Verify media belongs to the specified vendor
if media_file.vendor_id != vendor_id:
from app.modules.cms.exceptions import MediaNotFoundException
raise MediaNotFoundException(media_id)
media_service.delete_media(db=db, media_id=media_id)
logger.info(f"Admin deleted media {media_id} for vendor {vendor_id}")
return {"success": True, "message": "Media deleted successfully"}

View File

@@ -0,0 +1,234 @@
# app/modules/cms/routes/api/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.
All exceptions are handled by the global exception handler.
"""
import logging
from fastapi import APIRouter, Depends, Path
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_db
from app.modules.cms.services.vendor_theme_service import vendor_theme_service
from models.schema.auth import UserContext
from models.schema.vendor_theme import (
ThemeDeleteResponse,
ThemePresetListResponse,
ThemePresetResponse,
VendorThemeResponse,
VendorThemeUpdate,
)
admin_vendor_themes_router = APIRouter(prefix="/vendor-themes")
logger = logging.getLogger(__name__)
# ============================================================================
# PRESET ENDPOINTS
# ============================================================================
@admin_vendor_themes_router.get("/presets", response_model=ThemePresetListResponse)
async def get_theme_presets(current_admin: UserContext = Depends(get_current_admin_api)):
"""
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")
presets = vendor_theme_service.get_available_presets()
return ThemePresetListResponse(presets=presets)
# ============================================================================
# THEME RETRIEVAL
# ============================================================================
@admin_vendor_themes_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: UserContext = Depends(get_current_admin_api),
):
"""
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 (VendorNotFoundException)
"""
logger.info(f"Getting theme for vendor: {vendor_code}")
# Service raises VendorNotFoundException if vendor not found
# Global exception handler converts it to HTTP 404
theme = vendor_theme_service.get_theme(db, vendor_code)
return theme
# ============================================================================
# THEME UPDATE
# ============================================================================
@admin_vendor_themes_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: UserContext = Depends(get_current_admin_api),
):
"""
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 (VendorNotFoundException)
- `422`: Validation error (ThemeValidationException, InvalidColorFormatException, etc.)
- `500`: Operation failed (ThemeOperationException)
"""
logger.info(f"Updating theme for vendor: {vendor_code}")
# Service handles all validation and raises appropriate exceptions
# Global exception handler converts them to proper HTTP responses
theme = vendor_theme_service.update_theme(db, vendor_code, theme_data)
db.commit()
return VendorThemeResponse(**theme.to_dict())
# ============================================================================
# PRESET APPLICATION
# ============================================================================
@admin_vendor_themes_router.post("/{vendor_code}/preset/{preset_name}", response_model=ThemePresetResponse)
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: UserContext = Depends(get_current_admin_api),
):
"""
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 not found (VendorNotFoundException) or preset not found (ThemePresetNotFoundException)
- `500`: Operation failed (ThemeOperationException)
"""
logger.info(f"Applying preset '{preset_name}' to vendor {vendor_code}")
# Service validates preset name and applies it
# Raises ThemePresetNotFoundException if preset doesn't exist
# Global exception handler converts to HTTP 404
theme = vendor_theme_service.apply_theme_preset(db, vendor_code, preset_name)
db.commit()
return ThemePresetResponse(
message=f"Applied {preset_name} preset successfully",
theme=VendorThemeResponse(**theme.to_dict()),
)
# ============================================================================
# THEME DELETION
# ============================================================================
@admin_vendor_themes_router.delete("/{vendor_code}", response_model=ThemeDeleteResponse)
async def delete_vendor_theme(
vendor_code: str = Path(..., description="Vendor code"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
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 (VendorNotFoundException) or no custom theme (VendorThemeNotFoundException)
- `500`: Operation failed (ThemeOperationException)
"""
logger.info(f"Deleting theme for vendor: {vendor_code}")
# Service handles deletion and raises exceptions if needed
# Global exception handler converts them to proper HTTP responses
result = vendor_theme_service.delete_theme(db, vendor_code)
db.commit()
return ThemeDeleteResponse(
message=result.get("message", "Theme deleted successfully")
)

View File

@@ -25,7 +25,7 @@ from app.modules.cms.schemas import (
CMSUsageResponse,
)
from app.modules.cms.services import content_page_service
from app.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
from app.modules.tenancy.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
from models.database.user import User
vendor_service = VendorService()

View File

@@ -13,8 +13,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions.media import MediaOptimizationException
from app.services.media_service import media_service
from app.modules.cms.exceptions import MediaOptimizationException
from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext
from models.schema.media import (
MediaDetailResponse,

View File

@@ -0,0 +1,241 @@
# app/modules/cms/routes/pages/public.py
"""
CMS Public Page Routes (HTML rendering).
Public (unauthenticated) pages for platform content:
- Homepage
- Generic content pages (/{slug} catch-all)
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.billing.models import TIER_LIMITS, TierCode
from app.modules.cms.services import content_page_service
from app.modules.core.utils.page_context import get_public_context
from app.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter()
# Route configuration - high priority so catch-all is registered last
ROUTE_CONFIG = {
"priority": 100,
}
def _get_tiers_data() -> list[dict]:
"""Build tier data for display in templates."""
tiers = []
for tier_code, limits in TIER_LIMITS.items():
tiers.append(
{
"code": tier_code.value,
"name": limits["name"],
"price_monthly": limits["price_monthly_cents"] / 100,
"price_annual": (limits["price_annual_cents"] / 100)
if limits.get("price_annual_cents")
else None,
"orders_per_month": limits.get("orders_per_month"),
"products_limit": limits.get("products_limit"),
"team_members": limits.get("team_members"),
"features": limits.get("features", []),
"is_popular": tier_code == TierCode.PROFESSIONAL,
"is_enterprise": tier_code == TierCode.ENTERPRISE,
}
)
return tiers
# ============================================================================
# HOMEPAGE
# ============================================================================
@router.get("/", response_class=HTMLResponse, name="platform_homepage")
async def homepage(
request: Request,
db: Session = Depends(get_db),
):
"""
Homepage handler.
Handles two scenarios:
1. Vendor on custom domain (vendor.com) -> Show vendor landing page or redirect to shop
2. Platform marketing site -> Show platform homepage from CMS or default template
URL routing:
- localhost:9999/ -> Main marketing site ('main' platform)
- localhost:9999/platforms/oms/ -> OMS platform (middleware rewrites to /)
- oms.lu/ -> OMS platform (domain-based)
- shop.mycompany.com/ -> Vendor landing page (custom domain)
"""
# Get platform and vendor from middleware
platform = getattr(request.state, "platform", None)
vendor = getattr(request.state, "vendor", None)
# Scenario 1: Vendor detected (custom domain like vendor.com)
if vendor:
logger.debug(f"[HOMEPAGE] Vendor detected: {vendor.subdomain}")
# Get platform_id (use platform from context or default to 1 for OMS)
platform_id = platform.id if platform else 1
# Try to find vendor landing page (slug='landing' or 'home')
landing_page = content_page_service.get_page_for_vendor(
db,
platform_id=platform_id,
slug="landing",
vendor_id=vendor.id,
include_unpublished=False,
)
if not landing_page:
landing_page = content_page_service.get_page_for_vendor(
db,
platform_id=platform_id,
slug="home",
vendor_id=vendor.id,
include_unpublished=False,
)
if landing_page:
# Render landing page with selected template
from app.modules.core.utils.page_context import get_storefront_context
template_name = landing_page.template or "default"
template_path = f"cms/storefront/landing-{template_name}.html"
logger.info(f"[HOMEPAGE] Rendering vendor landing page: {template_path}")
return templates.TemplateResponse(
template_path,
get_storefront_context(request, db=db, page=landing_page),
)
# No landing page - redirect to shop
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
if vendor_context
else "unknown"
)
if access_method == "path":
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
return RedirectResponse(
url=f"{full_prefix}{vendor.subdomain}/storefront/", status_code=302
)
# Domain/subdomain - redirect to /storefront/
return RedirectResponse(url="/storefront/", status_code=302)
# Scenario 2: Platform marketing site (no vendor)
# Load platform homepage from CMS (slug='home')
platform_id = platform.id if platform else 1
cms_homepage = content_page_service.get_platform_page(
db, platform_id=platform_id, slug="home", include_unpublished=False
)
if cms_homepage:
# Use CMS-based homepage with template selection
context = get_public_context(request, db)
context["page"] = cms_homepage
context["tiers"] = _get_tiers_data()
template_name = cms_homepage.template or "default"
template_path = f"cms/public/homepage-{template_name}.html"
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
return templates.TemplateResponse(template_path, context)
# Fallback: Default wizamart homepage (no CMS content)
logger.info("[HOMEPAGE] No CMS homepage found, using default wizamart template")
context = get_public_context(request, db)
context["tiers"] = _get_tiers_data()
# Add-ons (hardcoded for now, will come from DB)
context["addons"] = [
{
"code": "domain",
"name": "Custom Domain",
"description": "Use your own domain (mydomain.com)",
"price": 15,
"billing_period": "year",
"icon": "globe",
},
{
"code": "ssl_premium",
"name": "Premium SSL",
"description": "EV certificate for trust badges",
"price": 49,
"billing_period": "year",
"icon": "shield-check",
},
{
"code": "email",
"name": "Email Package",
"description": "Professional email addresses",
"price": 5,
"billing_period": "month",
"icon": "mail",
"options": [
{"quantity": 5, "price": 5},
{"quantity": 10, "price": 9},
{"quantity": 25, "price": 19},
],
},
]
return templates.TemplateResponse(
"cms/public/homepage-wizamart.html",
context,
)
# ============================================================================
# GENERIC CONTENT PAGES (CMS)
# ============================================================================
# IMPORTANT: This route must be LAST as it catches all /{slug} URLs
@router.get("/{slug}", response_class=HTMLResponse, name="platform_content_page")
async def content_page(
request: Request,
slug: str,
db: Session = Depends(get_db),
):
"""
Serve CMS content pages (about, contact, faq, privacy, terms, etc.).
This is a catch-all route for dynamic content pages managed via the admin CMS.
Platform pages have vendor_id=None and is_platform_page=True.
"""
# Get platform from middleware (default to OMS platform_id=1)
platform = getattr(request.state, "platform", None)
platform_id = platform.id if platform else 1
# Load platform marketing page from database
page = content_page_service.get_platform_page(
db, platform_id=platform_id, slug=slug, include_unpublished=False
)
if not page:
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
context = get_public_context(request, db)
context["page"] = page
context["page_title"] = page.title
return templates.TemplateResponse(
"cms/public/content-page.html",
context,
)

View File

@@ -0,0 +1,175 @@
# app/modules/cms/routes/pages/storefront.py
"""
CMS Storefront Page Routes (HTML rendering).
Storefront (customer shop) pages for CMS content:
- Generic content pages (/{slug} catch-all)
- Debug context endpoint
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.modules.cms.services import content_page_service
from app.modules.core.utils.page_context import get_storefront_context
from app.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter()
# Route configuration - high priority so catch-all is registered last
ROUTE_CONFIG = {
"priority": 100,
}
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS)
# ============================================================================
@router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False)
async def generic_content_page(
request: Request,
slug: str = Path(..., description="Content page slug"),
db: Session = Depends(get_db),
):
"""
Generic content page handler (CMS).
Handles dynamic content pages like:
- /about, /faq, /contact, /shipping, /returns, /privacy, /terms, etc.
Features:
- Two-tier system: Vendor overrides take priority, fallback to platform defaults
- Only shows published pages
- Returns 404 if page not found
This route MUST be defined last in the router to avoid conflicts with
specific routes (like /products, /cart, /account, etc.)
"""
logger.debug(
"[CMS_STOREFRONT] generic_content_page REACHED",
extra={
"path": request.url.path,
"slug": slug,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
vendor = getattr(request.state, "vendor", None)
platform = getattr(request.state, "platform", None)
vendor_id = vendor.id if vendor else None
platform_id = platform.id if platform else 1 # Default to OMS
# Load content page from database (vendor override -> vendor default)
page = content_page_service.get_page_for_vendor(
db,
platform_id=platform_id,
slug=slug,
vendor_id=vendor_id,
include_unpublished=False,
)
if not page:
logger.warning(
"[CMS_STOREFRONT] Content page not found",
extra={
"slug": slug,
"vendor_id": vendor_id,
"vendor_name": vendor.name if vendor else None,
},
)
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
logger.info(
"[CMS_STOREFRONT] Content page found",
extra={
"slug": slug,
"page_id": page.id,
"page_title": page.title,
"is_vendor_override": page.vendor_id is not None,
"vendor_id": vendor_id,
},
)
return templates.TemplateResponse(
"cms/storefront/content-page.html",
get_storefront_context(request, db=db, page=page),
)
# ============================================================================
# DEBUG ENDPOINTS - For troubleshooting context issues
# ============================================================================
@router.get("/debug/context", response_class=HTMLResponse, include_in_schema=False)
async def debug_context(request: Request):
"""
DEBUG ENDPOINT: Display request context.
Shows what's available in request.state.
Useful for troubleshooting template variable issues.
URL: /storefront/debug/context
"""
import json
vendor = getattr(request.state, "vendor", None)
theme = getattr(request.state, "theme", None)
debug_info = {
"path": request.url.path,
"host": request.headers.get("host", ""),
"vendor": {
"found": vendor is not None,
"id": vendor.id if vendor else None,
"name": vendor.name if vendor else None,
"subdomain": vendor.subdomain if vendor else None,
"is_active": vendor.is_active if vendor else None,
},
"theme": {
"found": theme is not None,
"name": theme.get("theme_name") if theme else None,
},
"clean_path": getattr(request.state, "clean_path", "NOT SET"),
"context_type": str(getattr(request.state, "context_type", "NOT SET")),
}
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>Debug Context</title>
<style>
body {{ font-family: monospace; margin: 20px; }}
pre {{ background: #f0f0f0; padding: 20px; border-radius: 5px; }}
.good {{ color: green; }}
.bad {{ color: red; }}
</style>
</head>
<body>
<h1>Request Context Debug</h1>
<pre>{json.dumps(debug_info, indent=2)}</pre>
<h2>Status</h2>
<p class="{"good" if vendor else "bad"}">
Vendor: {"Found" if vendor else "Not Found"}
</p>
<p class="{"good" if theme else "bad"}">
Theme: {"Found" if theme else "Not Found"}
</p>
<p class="{"good" if str(getattr(request.state, "context_type", "NOT SET")) == "storefront" else "bad"}">
Context Type: {str(getattr(request.state, "context_type", "NOT SET"))}
</p>
</body>
</html>
"""
return HTMLResponse(content=html_content)

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.cms.services import content_page_service
from app.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service
from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service
from app.templates_config import templates
from models.database.user import User
from models.database.vendor import Vendor

View File

@@ -9,8 +9,26 @@ from app.modules.cms.services.content_page_service import (
ContentPageService,
content_page_service,
)
from app.modules.cms.services.media_service import (
MediaService,
media_service,
)
from app.modules.cms.services.vendor_theme_service import (
VendorThemeService,
vendor_theme_service,
)
from app.modules.cms.services.vendor_email_settings_service import (
VendorEmailSettingsService,
get_vendor_email_settings_service,
)
__all__ = [
"ContentPageService",
"content_page_service",
"MediaService",
"media_service",
"VendorThemeService",
"vendor_theme_service",
"VendorEmailSettingsService",
"get_vendor_email_settings_service",
]

View File

@@ -0,0 +1,552 @@
# app/modules/cms/services/media_service.py
"""
Media service for vendor media library management.
This module provides:
- File upload and storage
- Thumbnail generation for images
- Media metadata management
- Media usage tracking
"""
import logging
import mimetypes
import os
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.modules.cms.exceptions import (
MediaNotFoundException,
MediaUploadException,
MediaValidationException,
UnsupportedMediaTypeException,
MediaFileTooLargeException,
)
from models.database.media import MediaFile
from app.modules.catalog.models import ProductMedia
logger = logging.getLogger(__name__)
# Base upload directory
UPLOAD_DIR = Path("uploads")
VENDOR_UPLOAD_DIR = UPLOAD_DIR / "vendors"
# Allowed file types and their categories
ALLOWED_EXTENSIONS = {
# Images
"jpg": "image",
"jpeg": "image",
"png": "image",
"gif": "image",
"webp": "image",
"svg": "image",
# Videos
"mp4": "video",
"webm": "video",
"mov": "video",
# Documents
"pdf": "document",
"doc": "document",
"docx": "document",
"xls": "document",
"xlsx": "document",
"csv": "document",
"txt": "document",
}
# Maximum file sizes (in bytes)
MAX_FILE_SIZES = {
"image": 10 * 1024 * 1024, # 10 MB
"video": 100 * 1024 * 1024, # 100 MB
"document": 20 * 1024 * 1024, # 20 MB
}
# Thumbnail settings
THUMBNAIL_SIZE = (200, 200)
class MediaService:
"""Service for vendor media library operations."""
def _get_vendor_upload_path(self, vendor_id: int, folder: str = "general") -> Path:
"""Get the upload directory path for a vendor."""
return VENDOR_UPLOAD_DIR / str(vendor_id) / folder
def _ensure_upload_dir(self, path: Path) -> None:
"""Ensure upload directory exists."""
path.mkdir(parents=True, exist_ok=True)
def _get_file_extension(self, filename: str) -> str:
"""Extract file extension from filename."""
return filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
def _get_media_type(self, extension: str) -> str | None:
"""Get media type from file extension."""
return ALLOWED_EXTENSIONS.get(extension)
def _generate_unique_filename(self, original_filename: str) -> str:
"""Generate a unique filename using UUID."""
ext = self._get_file_extension(original_filename)
return f"{uuid.uuid4().hex}.{ext}" if ext else uuid.uuid4().hex
def _validate_file(
self, filename: str, file_size: int
) -> tuple[str, str]:
"""
Validate uploaded file.
Returns:
Tuple of (extension, media_type)
Raises:
MediaValidationException: If file is invalid
UnsupportedMediaTypeException: If file type is not supported
MediaFileTooLargeException: If file exceeds size limit
"""
ext = self._get_file_extension(filename)
if not ext:
raise MediaValidationException("File must have an extension", field="file")
media_type = self._get_media_type(ext)
if not media_type:
raise UnsupportedMediaTypeException(
ext, allowed_types=list(ALLOWED_EXTENSIONS.keys())
)
max_size = MAX_FILE_SIZES.get(media_type, 10 * 1024 * 1024)
if file_size > max_size:
raise MediaFileTooLargeException(file_size, max_size, media_type)
return ext, media_type
def _get_image_dimensions(self, file_path: Path) -> tuple[int, int] | None:
"""Get image dimensions if PIL is available."""
try:
from PIL import Image
with Image.open(file_path) as img:
return img.size
except ImportError:
logger.debug("PIL not available, skipping image dimension detection")
return None
except Exception as e:
logger.warning(f"Could not get image dimensions: {e}")
return None
def _generate_thumbnail(
self, source_path: Path, vendor_id: int
) -> str | None:
"""Generate thumbnail for image file."""
try:
from PIL import Image
# Create thumbnails directory
thumb_dir = self._get_vendor_upload_path(vendor_id, "thumbnails")
self._ensure_upload_dir(thumb_dir)
# Generate thumbnail filename
thumb_filename = f"thumb_{source_path.name}"
thumb_path = thumb_dir / thumb_filename
# Create thumbnail
with Image.open(source_path) as img:
img.thumbnail(THUMBNAIL_SIZE)
# Convert to RGB if needed (for PNG with transparency)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(thumb_path, "JPEG", quality=85)
# Return relative path
return str(thumb_path.relative_to(UPLOAD_DIR))
except ImportError:
logger.debug("PIL not available, skipping thumbnail generation")
return None
except Exception as e:
logger.warning(f"Could not generate thumbnail: {e}")
return None
async def upload_file(
self,
db: Session,
vendor_id: int,
file_content: bytes,
filename: str,
folder: str = "general",
) -> MediaFile:
"""
Upload a file to the media library.
Args:
db: Database session
vendor_id: Vendor ID
file_content: File content as bytes
filename: Original filename
folder: Folder to store in (products, general, etc.)
Returns:
Created MediaFile record
"""
# Validate file
file_size = len(file_content)
ext, media_type = self._validate_file(filename, file_size)
# Generate unique filename
unique_filename = self._generate_unique_filename(filename)
# Get upload path
upload_path = self._get_vendor_upload_path(vendor_id, folder)
self._ensure_upload_dir(upload_path)
# Save file
file_path = upload_path / unique_filename
file_path.write_bytes(file_content)
# Get relative path for storage
relative_path = str(file_path.relative_to(UPLOAD_DIR))
# Get MIME type
mime_type, _ = mimetypes.guess_type(filename)
# Get image dimensions and generate thumbnail
width, height = None, None
thumbnail_path = None
if media_type == "image":
dimensions = self._get_image_dimensions(file_path)
if dimensions:
width, height = dimensions
thumbnail_path = self._generate_thumbnail(file_path, vendor_id)
# Create database record
media_file = MediaFile(
vendor_id=vendor_id,
filename=unique_filename,
original_filename=filename,
file_path=relative_path,
media_type=media_type,
mime_type=mime_type,
file_size=file_size,
width=width,
height=height,
thumbnail_path=thumbnail_path,
folder=folder,
)
db.add(media_file)
db.flush()
db.refresh(media_file)
logger.info(
f"Uploaded media file {media_file.id} for vendor {vendor_id}: {filename}"
)
return media_file
def get_media(
self, db: Session, vendor_id: int, media_id: int
) -> MediaFile:
"""
Get a media file by ID.
Raises:
MediaNotFoundException: If media not found or doesn't belong to vendor
"""
media = (
db.query(MediaFile)
.filter(
MediaFile.id == media_id,
MediaFile.vendor_id == vendor_id,
)
.first()
)
if not media:
raise MediaNotFoundException(media_id)
return media
def get_media_library(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
media_type: str | None = None,
folder: str | None = None,
search: str | None = None,
) -> tuple[list[MediaFile], int]:
"""
Get vendor media library with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
media_type: Filter by media type
folder: Filter by folder
search: Search in filename
Returns:
Tuple of (media_files, total_count)
"""
query = db.query(MediaFile).filter(MediaFile.vendor_id == vendor_id)
if media_type:
query = query.filter(MediaFile.media_type == media_type)
if folder:
query = query.filter(MediaFile.folder == folder)
if search:
search_pattern = f"%{search}%"
query = query.filter(
or_(
MediaFile.filename.ilike(search_pattern),
MediaFile.original_filename.ilike(search_pattern),
MediaFile.alt_text.ilike(search_pattern),
)
)
# Order by newest first
query = query.order_by(MediaFile.created_at.desc())
total = query.count()
media_files = query.offset(skip).limit(limit).all()
return media_files, total
def update_media_metadata(
self,
db: Session,
vendor_id: int,
media_id: int,
filename: str | None = None,
alt_text: str | None = None,
description: str | None = None,
folder: str | None = None,
metadata: dict | None = None,
) -> MediaFile:
"""
Update media file metadata.
Args:
db: Database session
vendor_id: Vendor ID
media_id: Media file ID
filename: New display filename
alt_text: Alt text for images
description: File description
folder: Move to different folder
metadata: Additional metadata
Returns:
Updated MediaFile
"""
media = self.get_media(db, vendor_id, media_id)
if filename is not None:
media.original_filename = filename
if alt_text is not None:
media.alt_text = alt_text
if description is not None:
media.description = description
if folder is not None and folder != media.folder:
# Move file to new folder
old_path = UPLOAD_DIR / media.file_path
new_dir = self._get_vendor_upload_path(vendor_id, folder)
self._ensure_upload_dir(new_dir)
new_path = new_dir / media.filename
if old_path.exists():
shutil.move(str(old_path), str(new_path))
media.file_path = str(new_path.relative_to(UPLOAD_DIR))
media.folder = folder
if metadata is not None:
media.extra_metadata = metadata
media.updated_at = datetime.now(UTC)
db.flush()
logger.info(f"Updated media metadata for {media_id}")
return media
def delete_media(
self, db: Session, vendor_id: int, media_id: int
) -> bool:
"""
Delete a media file.
Args:
db: Database session
vendor_id: Vendor ID
media_id: Media file ID
Returns:
True if deleted successfully
"""
media = self.get_media(db, vendor_id, media_id)
# Delete physical files
file_path = UPLOAD_DIR / media.file_path
if file_path.exists():
file_path.unlink()
if media.thumbnail_path:
thumb_path = UPLOAD_DIR / media.thumbnail_path
if thumb_path.exists():
thumb_path.unlink()
# Delete database record
db.delete(media)
logger.info(f"Deleted media file {media_id} for vendor {vendor_id}")
return True
def get_media_usage(
self, db: Session, vendor_id: int, media_id: int
) -> dict:
"""
Get where a media file is being used.
Returns:
Dict with products and other usage information
"""
media = self.get_media(db, vendor_id, media_id)
# Get product associations
product_usage = []
for assoc in media.product_associations:
product = assoc.product
if product:
product_usage.append({
"product_id": product.id,
"product_name": product.get_title() or f"Product {product.id}",
"usage_type": assoc.usage_type,
})
return {
"media_id": media_id,
"products": product_usage,
"other_usage": [],
"total_usage_count": len(product_usage),
}
def attach_to_product(
self,
db: Session,
vendor_id: int,
media_id: int,
product_id: int,
usage_type: str = "gallery",
display_order: int = 0,
) -> ProductMedia:
"""
Attach a media file to a product.
Args:
db: Database session
vendor_id: Vendor ID
media_id: Media file ID
product_id: Product ID
usage_type: How the media is used (main_image, gallery, etc.)
display_order: Order for galleries
Returns:
Created ProductMedia association
"""
# Verify media belongs to vendor
media = self.get_media(db, vendor_id, media_id)
# Check if already attached with same usage type
existing = (
db.query(ProductMedia)
.filter(
ProductMedia.product_id == product_id,
ProductMedia.media_id == media_id,
ProductMedia.usage_type == usage_type,
)
.first()
)
if existing:
existing.display_order = display_order
db.flush()
return existing
# Create association
product_media = ProductMedia(
product_id=product_id,
media_id=media_id,
usage_type=usage_type,
display_order=display_order,
)
db.add(product_media)
# Update usage count
media.usage_count = (media.usage_count or 0) + 1
db.flush()
return product_media
def detach_from_product(
self,
db: Session,
vendor_id: int,
media_id: int,
product_id: int,
usage_type: str | None = None,
) -> bool:
"""
Detach a media file from a product.
Args:
db: Database session
vendor_id: Vendor ID
media_id: Media file ID
product_id: Product ID
usage_type: Specific usage type to remove (None = all)
Returns:
True if detached
"""
# Verify media belongs to vendor
media = self.get_media(db, vendor_id, media_id)
query = db.query(ProductMedia).filter(
ProductMedia.product_id == product_id,
ProductMedia.media_id == media_id,
)
if usage_type:
query = query.filter(ProductMedia.usage_type == usage_type)
deleted_count = query.delete()
# Update usage count
if deleted_count > 0:
media.usage_count = max(0, (media.usage_count or 0) - deleted_count)
db.flush()
return deleted_count > 0
# Create service instance
media_service = MediaService()

View File

@@ -0,0 +1,483 @@
# app/modules/cms/services/vendor_email_settings_service.py
"""
Vendor Email Settings Service.
Handles CRUD operations for vendor email configuration:
- SMTP settings
- Advanced providers (SendGrid, Mailgun, SES) - tier-gated
- Sender identity (from_email, from_name, reply_to)
- Signature/footer customization
- Configuration verification via test email
"""
import logging
import smtplib
from datetime import UTC, datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from sqlalchemy.orm import Session
from app.exceptions import (
AuthorizationException,
ResourceNotFoundException,
ValidationException,
ExternalServiceException,
)
from models.database import (
Vendor,
VendorEmailSettings,
EmailProvider,
PREMIUM_EMAIL_PROVIDERS,
VendorSubscription,
TierCode,
)
logger = logging.getLogger(__name__)
# Tiers that allow premium email providers
PREMIUM_TIERS = {TierCode.BUSINESS, TierCode.ENTERPRISE}
class VendorEmailSettingsService:
"""Service for managing vendor email settings."""
def __init__(self, db: Session):
self.db = db
# =========================================================================
# READ OPERATIONS
# =========================================================================
def get_settings(self, vendor_id: int) -> VendorEmailSettings | None:
"""Get email settings for a vendor."""
return (
self.db.query(VendorEmailSettings)
.filter(VendorEmailSettings.vendor_id == vendor_id)
.first()
)
def get_settings_or_404(self, vendor_id: int) -> VendorEmailSettings:
"""Get email settings or raise 404."""
settings = self.get_settings(vendor_id)
if not settings:
raise ResourceNotFoundException(
resource_type="vendor_email_settings",
identifier=str(vendor_id),
)
return settings
def is_configured(self, vendor_id: int) -> bool:
"""Check if vendor has configured email settings."""
settings = self.get_settings(vendor_id)
return settings is not None and settings.is_configured
def get_status(self, vendor_id: int) -> dict:
"""
Get email configuration status for a vendor.
Returns:
dict with is_configured, is_verified, provider, etc.
"""
settings = self.get_settings(vendor_id)
if not settings:
return {
"is_configured": False,
"is_verified": False,
"provider": None,
"from_email": None,
"from_name": None,
"message": "Email settings not configured. Configure SMTP to send emails.",
}
return {
"is_configured": settings.is_configured,
"is_verified": settings.is_verified,
"provider": settings.provider,
"from_email": settings.from_email,
"from_name": settings.from_name,
"last_verified_at": settings.last_verified_at.isoformat() if settings.last_verified_at else None,
"verification_error": settings.verification_error,
"message": self._get_status_message(settings),
}
def _get_status_message(self, settings: VendorEmailSettings) -> str:
"""Generate a human-readable status message."""
if not settings.is_configured:
return "Complete your email configuration to send emails."
if not settings.is_verified:
return "Email configured but not verified. Send a test email to verify."
return "Email settings configured and verified."
# =========================================================================
# WRITE OPERATIONS
# =========================================================================
def create_or_update(
self,
vendor_id: int,
data: dict,
current_tier: TierCode | None = None,
) -> VendorEmailSettings:
"""
Create or update vendor email settings.
Args:
vendor_id: Vendor ID
data: Settings data (from_email, from_name, smtp_*, etc.)
current_tier: Vendor's current subscription tier (for premium provider validation)
Returns:
Updated VendorEmailSettings
Raises:
AuthorizationException: If trying to use premium provider without required tier
"""
# Validate premium provider access
provider = data.get("provider", "smtp")
if provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]:
if current_tier not in PREMIUM_TIERS:
raise AuthorizationException(
message=f"Provider '{provider}' requires Business or Enterprise tier. "
"Upgrade your plan to use advanced email providers.",
details={"required_permission": "business_tier"},
)
settings = self.get_settings(vendor_id)
if not settings:
settings = VendorEmailSettings(vendor_id=vendor_id)
self.db.add(settings)
# Update fields
for field in [
"from_email",
"from_name",
"reply_to_email",
"signature_text",
"signature_html",
"provider",
# SMTP
"smtp_host",
"smtp_port",
"smtp_username",
"smtp_password",
"smtp_use_tls",
"smtp_use_ssl",
# SendGrid
"sendgrid_api_key",
# Mailgun
"mailgun_api_key",
"mailgun_domain",
# SES
"ses_access_key_id",
"ses_secret_access_key",
"ses_region",
]:
if field in data and data[field] is not None:
# Don't overwrite passwords/keys with empty strings
if field.endswith(("_password", "_key", "_access_key")) and data[field] == "":
continue
setattr(settings, field, data[field])
# Update configuration status
settings.update_configuration_status()
# Reset verification if provider/credentials changed
if any(
f in data
for f in ["provider", "smtp_host", "smtp_password", "sendgrid_api_key", "mailgun_api_key", "ses_access_key_id"]
):
settings.is_verified = False
settings.verification_error = None
self.db.flush()
logger.info(f"Updated email settings for vendor {vendor_id}: provider={settings.provider}")
return settings
def delete(self, vendor_id: int) -> None:
"""
Delete email settings for a vendor.
Raises:
ResourceNotFoundException: If settings not found
"""
settings = self.get_settings(vendor_id)
if not settings:
raise ResourceNotFoundException(
resource_type="vendor_email_settings",
identifier=str(vendor_id),
)
self.db.delete(settings)
self.db.flush()
logger.info(f"Deleted email settings for vendor {vendor_id}")
# =========================================================================
# VERIFICATION
# =========================================================================
def verify_settings(self, vendor_id: int, test_email: str) -> dict:
"""
Verify email settings by sending a test email.
Args:
vendor_id: Vendor ID
test_email: Email address to send test email to
Returns:
dict with success status and message
Raises:
ResourceNotFoundException: If settings not found
ValidationException: If settings incomplete
"""
settings = self.get_settings_or_404(vendor_id)
if not settings.is_fully_configured():
raise ValidationException(
message="Email settings incomplete. Configure all required fields first.",
field="settings",
)
try:
# Send test email based on provider
if settings.provider == EmailProvider.SMTP.value:
self._send_smtp_test(settings, test_email)
elif settings.provider == EmailProvider.SENDGRID.value:
self._send_sendgrid_test(settings, test_email)
elif settings.provider == EmailProvider.MAILGUN.value:
self._send_mailgun_test(settings, test_email)
elif settings.provider == EmailProvider.SES.value:
self._send_ses_test(settings, test_email)
else:
raise ValidationException(
message=f"Unknown provider: {settings.provider}",
field="provider",
)
# Mark as verified
settings.mark_verified()
self.db.flush()
logger.info(f"Email settings verified for vendor {vendor_id}")
return {
"success": True,
"message": f"Test email sent successfully to {test_email}",
}
except (ValidationException, ExternalServiceException):
raise # Re-raise domain exceptions
except Exception as e:
error_msg = str(e)
settings.mark_verification_failed(error_msg)
self.db.flush()
logger.warning(f"Email verification failed for vendor {vendor_id}: {error_msg}")
# Return error dict instead of raising - verification failure is not a server error
return {
"success": False,
"message": f"Failed to send test email: {error_msg}",
}
def _send_smtp_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via SMTP."""
msg = MIMEMultipart("alternative")
msg["Subject"] = "Wizamart Email Configuration Test"
msg["From"] = f"{settings.from_name} <{settings.from_email}>"
msg["To"] = to_email
text_content = (
"This is a test email from Wizamart.\n\n"
"Your email settings are configured correctly!\n\n"
f"Provider: SMTP\n"
f"Host: {settings.smtp_host}\n"
)
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your email settings are configured correctly!
</p>
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Provider: SMTP<br>
Host: {settings.smtp_host}<br>
Sent at: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}
</p>
</body>
</html>
"""
msg.attach(MIMEText(text_content, "plain"))
msg.attach(MIMEText(html_content, "html"))
# Connect and send
if settings.smtp_use_ssl:
server = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port)
else:
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port)
if settings.smtp_use_tls:
server.starttls()
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.from_email, to_email, msg.as_string())
server.quit()
def _send_sendgrid_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via SendGrid."""
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
except ImportError:
raise ExternalServiceException(
service_name="SendGrid",
message="SendGrid library not installed. Contact support.",
)
message = Mail(
from_email=(settings.from_email, settings.from_name),
to_emails=to_email,
subject="Wizamart Email Configuration Test",
html_content=f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your SendGrid settings are configured correctly!
</p>
</body>
</html>
""",
)
sg = SendGridAPIClient(settings.sendgrid_api_key)
response = sg.send(message)
if response.status_code >= 400:
raise ExternalServiceException(
service_name="SendGrid",
message=f"SendGrid error: HTTP {response.status_code}",
)
def _send_mailgun_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via Mailgun."""
import requests
response = requests.post(
f"https://api.mailgun.net/v3/{settings.mailgun_domain}/messages",
auth=("api", settings.mailgun_api_key),
data={
"from": f"{settings.from_name} <{settings.from_email}>",
"to": to_email,
"subject": "Wizamart Email Configuration Test",
"html": f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your Mailgun settings are configured correctly!
</p>
</body>
</html>
""",
},
timeout=30,
)
if response.status_code >= 400:
raise ExternalServiceException(
service_name="Mailgun",
message=f"Mailgun error: {response.text}",
)
def _send_ses_test(self, settings: VendorEmailSettings, to_email: str) -> None:
"""Send test email via Amazon SES."""
try:
import boto3
except ImportError:
raise ExternalServiceException(
service_name="Amazon SES",
message="boto3 library not installed. Contact support.",
)
client = boto3.client(
"ses",
region_name=settings.ses_region,
aws_access_key_id=settings.ses_access_key_id,
aws_secret_access_key=settings.ses_secret_access_key,
)
client.send_email(
Source=f"{settings.from_name} <{settings.from_email}>",
Destination={"ToAddresses": [to_email]},
Message={
"Subject": {"Data": "Wizamart Email Configuration Test"},
"Body": {
"Html": {
"Data": f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Email Configuration Test</h2>
<p>This is a test email from <strong>Wizamart</strong>.</p>
<p style="color: #22c55e; font-weight: bold;">
Your Amazon SES settings are configured correctly!
</p>
</body>
</html>
"""
}
},
},
)
# =========================================================================
# TIER HELPERS
# =========================================================================
def get_available_providers(self, tier: TierCode | None) -> list[dict]:
"""
Get list of available email providers for a tier.
Returns list of providers with availability status.
"""
providers = [
{
"code": EmailProvider.SMTP.value,
"name": "SMTP",
"description": "Standard SMTP email server",
"available": True,
"tier_required": None,
},
{
"code": EmailProvider.SENDGRID.value,
"name": "SendGrid",
"description": "SendGrid email delivery platform",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
{
"code": EmailProvider.MAILGUN.value,
"name": "Mailgun",
"description": "Mailgun email API",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
{
"code": EmailProvider.SES.value,
"name": "Amazon SES",
"description": "Amazon Simple Email Service",
"available": tier in PREMIUM_TIERS if tier else False,
"tier_required": "business",
},
]
return providers
# Module-level service factory
def get_vendor_email_settings_service(db: Session) -> VendorEmailSettingsService:
"""Factory function to get a VendorEmailSettingsService instance."""
return VendorEmailSettingsService(db)

View File

@@ -0,0 +1,488 @@
# app/modules/cms/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 sqlalchemy.orm import Session
from app.core.theme_presets import (
THEME_PRESETS,
apply_preset,
get_available_presets,
get_preset_preview,
)
from app.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.cms.exceptions import (
InvalidColorFormatException,
InvalidFontFamilyException,
ThemeOperationException,
ThemePresetNotFoundException,
ThemeValidationException,
VendorThemeNotFoundException,
)
from models.database.vendor import Vendor
from models.database.vendor_theme import VendorTheme
from models.schema.vendor_theme import ThemePresetPreview, VendorThemeUpdate
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)
# Flush changes
db.flush()
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:
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)
# Flush changes
db.flush()
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:
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)
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:
self.logger.error(f"Failed to delete theme for vendor {vendor_code}: {e}")
raise ThemeOperationException(
operation="delete", vendor_code=vendor_code, reason=str(e)
)
# ============================================================================
# VALIDATION
# ============================================================================
def _validate_theme_data(self, theme_data: VendorThemeUpdate) -> None:
"""
Validate theme data before applying.
Args:
theme_data: Theme update data
Raises:
ThemeValidationException: If validation fails
InvalidColorFormatException: If color format invalid
InvalidFontFamilyException: If font family invalid
"""
# Validate colors
if theme_data.colors:
for color_key, color_value in theme_data.colors.items():
if not self._is_valid_color(color_value):
raise InvalidColorFormatException(color_value, color_key)
# Validate fonts
if theme_data.fonts:
for font_key, font_value in theme_data.fonts.items():
if not self._is_valid_font(font_value):
raise InvalidFontFamilyException(font_value, font_key)
# Validate layout values
if theme_data.layout:
valid_layouts = {
"style": ["grid", "list", "masonry"],
"header": ["fixed", "static", "transparent"],
"product_card": ["modern", "classic", "minimal"],
}
for layout_key, layout_value in theme_data.layout.items():
if layout_key in valid_layouts:
if layout_value not in valid_layouts[layout_key]:
raise ThemeValidationException(
message=f"Invalid {layout_key} value: {layout_value}",
field=layout_key,
validation_errors={
layout_key: f"Must be one of: {', '.join(valid_layouts[layout_key])}"
},
)
def _is_valid_color(self, color: str) -> bool:
"""
Validate color format (hex color).
Args:
color: Color string to validate
Returns:
True if valid, False otherwise
"""
if not color:
return False
# Check for hex color format (#RGB or #RRGGBB)
hex_pattern = r"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
return bool(re.match(hex_pattern, color))
def _is_valid_font(self, font: str) -> bool:
"""
Validate font family format.
Args:
font: Font family string to validate
Returns:
True if valid, False otherwise
"""
if not font or len(font) < 3:
return False
# Basic validation - font should not be empty and should be reasonable length
return len(font) <= 200
# ============================================================================
# SERVICE INSTANCE
# ============================================================================
vendor_theme_service = VendorThemeService()

View File

@@ -0,0 +1,307 @@
// static/shared/js/media-picker.js
/**
* Media Picker Helper Functions
*
* Provides Alpine.js mixin for media library picker functionality.
* Used in product create/edit forms to select images from vendor's media library.
*
* Usage:
* In your Alpine component:
* return {
* ...mediaPickerMixin(vendorIdGetter, multiSelect),
* // your other data/methods
* }
*/
// Use centralized logger
const mediaPickerLog = window.LogConfig.loggers.mediaPicker ||
window.LogConfig.createLogger('mediaPicker', false);
/**
* Create media picker mixin for Alpine.js components
*
* @param {Function} vendorIdGetter - Function that returns the current vendor ID
* @param {boolean} multiSelect - Allow selecting multiple images
* @returns {Object} Alpine.js mixin object
*/
function mediaPickerMixin(vendorIdGetter, multiSelect = false) {
return {
// Modal visibility
showMediaPicker: false,
showMediaPickerAdditional: false,
// Picker state
mediaPickerState: {
loading: false,
uploading: false,
media: [],
selected: [],
total: 0,
skip: 0,
limit: 24,
search: '',
},
// Which picker is active (main or additional)
activePickerTarget: 'main',
/**
* Open media picker for main image
*/
openMediaPickerMain() {
this.activePickerTarget = 'main';
this.mediaPickerState.selected = [];
this.showMediaPicker = true;
},
/**
* Open media picker for additional images
*/
openMediaPickerAdditional() {
this.activePickerTarget = 'additional';
this.mediaPickerState.selected = [];
this.showMediaPickerAdditional = true;
},
/**
* Load media library from API
*/
async loadMediaLibrary() {
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
if (!vendorId) {
mediaPickerLog.warn('No vendor ID available');
return;
}
this.mediaPickerState.loading = true;
this.mediaPickerState.skip = 0;
try {
const params = new URLSearchParams({
skip: '0',
limit: this.mediaPickerState.limit.toString(),
media_type: 'image',
});
if (this.mediaPickerState.search) {
params.append('search', this.mediaPickerState.search);
}
const response = await apiClient.get(
`/admin/media/vendors/${vendorId}?${params.toString()}`
);
this.mediaPickerState.media = response.media || [];
this.mediaPickerState.total = response.total || 0;
} catch (error) {
mediaPickerLog.error('Failed to load media library:', error);
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Failed to load media library', type: 'error' }
}));
} finally {
this.mediaPickerState.loading = false;
}
},
/**
* Load more media (pagination)
*/
async loadMoreMedia() {
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
if (!vendorId) return;
this.mediaPickerState.loading = true;
this.mediaPickerState.skip += this.mediaPickerState.limit;
try {
const params = new URLSearchParams({
skip: this.mediaPickerState.skip.toString(),
limit: this.mediaPickerState.limit.toString(),
media_type: 'image',
});
if (this.mediaPickerState.search) {
params.append('search', this.mediaPickerState.search);
}
const response = await apiClient.get(
`/admin/media/vendors/${vendorId}?${params.toString()}`
);
this.mediaPickerState.media = [
...this.mediaPickerState.media,
...(response.media || [])
];
} catch (error) {
mediaPickerLog.error('Failed to load more media:', error);
} finally {
this.mediaPickerState.loading = false;
}
},
/**
* Upload a new media file
*/
async uploadMediaFile(event) {
const file = event.target.files?.[0];
if (!file) return;
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
if (!vendorId) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Please select a vendor first', type: 'error' }
}));
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Please select an image file', type: 'error' }
}));
return;
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Image must be less than 10MB', type: 'error' }
}));
return;
}
this.mediaPickerState.uploading = true;
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.postFormData(
`/admin/media/vendors/${vendorId}/upload?folder=products`,
formData
);
if (response.success && response.media) {
// Add to beginning of media list
this.mediaPickerState.media.unshift(response.media);
this.mediaPickerState.total++;
// Auto-select the uploaded image
this.toggleMediaSelection(response.media);
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Image uploaded successfully', type: 'success' }
}));
}
} catch (error) {
mediaPickerLog.error('Failed to upload image:', error);
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: error.message || 'Failed to upload image', type: 'error' }
}));
} finally {
this.mediaPickerState.uploading = false;
// Clear the file input
event.target.value = '';
}
},
/**
* Toggle media selection
*/
toggleMediaSelection(media) {
const index = this.mediaPickerState.selected.findIndex(m => m.id === media.id);
if (index > -1) {
// Deselect
this.mediaPickerState.selected.splice(index, 1);
} else {
if (multiSelect) {
// Multi-select: add to selection
this.mediaPickerState.selected.push(media);
} else {
// Single-select: replace selection
this.mediaPickerState.selected = [media];
}
}
},
/**
* Check if media is selected
*/
isMediaSelected(mediaId) {
return this.mediaPickerState.selected.some(m => m.id === mediaId);
},
/**
* Confirm selection and call the appropriate callback
*/
confirmMediaSelection() {
const selected = this.mediaPickerState.selected;
if (selected.length === 0) return;
if (this.activePickerTarget === 'main') {
// Main image: use first selected
this.setMainImage(selected[0]);
this.showMediaPicker = false;
} else {
// Additional images: add all selected
this.addAdditionalImages(selected);
this.showMediaPickerAdditional = false;
}
// Clear selection
this.mediaPickerState.selected = [];
},
/**
* Set the main image (override in your component)
*/
setMainImage(media) {
if (this.form) {
this.form.primary_image_url = media.url;
}
mediaPickerLog.info('Main image set:', media.url);
},
/**
* Add additional images (override in your component)
*/
addAdditionalImages(mediaList) {
if (this.form && Array.isArray(this.form.additional_images)) {
const newUrls = mediaList.map(m => m.url);
this.form.additional_images = [
...this.form.additional_images,
...newUrls
];
}
mediaPickerLog.info('Additional images added:', mediaList.map(m => m.url));
},
/**
* Remove an additional image by index
*/
removeAdditionalImage(index) {
if (this.form && Array.isArray(this.form.additional_images)) {
this.form.additional_images.splice(index, 1);
}
},
/**
* Clear the main image
*/
clearMainImage() {
if (this.form) {
this.form.primary_image_url = '';
}
},
};
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = { mediaPickerMixin };
}

View File

@@ -0,0 +1,246 @@
{# app/templates/platform/content-page.html #}
{# Generic template for platform content pages (About, FAQ, Terms, Contact, etc.) #}
{% extends "public/base.html" %}
{% block title %}{{ page.title }} - Marketplace{% endblock %}
{% block meta_description %}
{% if page.meta_description %}
{{ page.meta_description }}
{% else %}
{{ page.title }} - Multi-Vendor Marketplace Platform
{% endif %}
{% endblock %}
{% block meta_keywords %}
{% if page.meta_keywords %}
{{ page.meta_keywords }}
{% else %}
{{ page.title }}, marketplace, platform
{% endif %}
{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{# Breadcrumbs #}
<nav class="flex mb-8 text-sm" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-2">
<li class="inline-flex items-center">
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary transition-colors">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path>
</svg>
Home
</a>
</li>
<li>
<div class="flex items-center">
<svg class="w-4 h-4 text-gray-400 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
</svg>
<span class="text-gray-700 dark:text-gray-300 font-medium">{{ page.title }}</span>
</div>
</li>
</ol>
</nav>
{# Page Header #}
<div class="mb-12">
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
{{ page.title }}
</h1>
{# Published date (if available) #}
{% if page.published_at %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span>Published {{ page.published_at.strftime('%B %d, %Y') }}</span>
</div>
{% endif %}
</div>
{# Page Content #}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-8 md:p-12">
<div class="prose prose-lg dark:prose-invert max-w-none">
{% if page.content_format == 'markdown' %}
{# Future enhancement: Render with markdown library #}
<div class="markdown-content">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
{% else %}
{# HTML content (default) #}
{{ page.content | safe }}{# sanitized: CMS content #}
{% endif %}
</div>
</div>
{# Last updated timestamp #}
{% if page.updated_at %}
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
</p>
</div>
{% endif %}
{# Call-to-action section (for specific pages) #}
{% if page.slug in ['about', 'contact'] %}
<div class="mt-12 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-2xl p-8 text-center">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{% if page.slug == 'about' %}
Ready to Get Started?
{% elif page.slug == 'contact' %}
Have Questions?
{% endif %}
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
{% if page.slug == 'about' %}
Join thousands of vendors already selling on our platform
{% elif page.slug == 'contact' %}
Our team is here to help you succeed
{% endif %}
</p>
<a href="/contact" class="inline-block bg-gray-900 dark:bg-white text-white dark:text-gray-900 px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition">
{% if page.slug == 'about' %}
Contact Sales
{% elif page.slug == 'contact' %}
Send Us a Message
{% endif %}
</a>
</div>
{% endif %}
</div>
{# Additional styling for prose content #}
<style>
/* Enhanced prose styling for content pages */
.prose {
color: inherit;
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
color: inherit;
font-weight: 700;
margin-top: 2em;
margin-bottom: 1em;
}
.prose h2 {
font-size: 1.875rem;
line-height: 2.25rem;
border-bottom: 2px solid var(--color-primary);
padding-bottom: 0.5rem;
}
.prose h3 {
font-size: 1.5rem;
line-height: 2rem;
}
.prose p {
margin-bottom: 1.5em;
line-height: 1.75;
}
.prose ul, .prose ol {
margin-bottom: 1.5em;
padding-left: 1.5em;
}
.prose li {
margin-bottom: 0.5em;
}
.prose a {
color: var(--color-primary);
text-decoration: underline;
font-weight: 500;
}
.prose a:hover {
opacity: 0.8;
}
.prose strong {
font-weight: 600;
color: inherit;
}
.prose code {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-size: 0.9em;
}
.dark .prose code {
background-color: rgba(255, 255, 255, 0.1);
}
.prose pre {
background-color: rgba(0, 0, 0, 0.05);
padding: 1em;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1.5em;
}
.dark .prose pre {
background-color: rgba(255, 255, 255, 0.05);
}
.prose blockquote {
border-left: 4px solid var(--color-primary);
padding-left: 1em;
font-style: italic;
opacity: 0.9;
margin: 1.5em 0;
}
.prose table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5em;
}
.prose th, .prose td {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 0.75em;
text-align: left;
}
.dark .prose th, .dark .prose td {
border-color: rgba(255, 255, 255, 0.1);
}
.prose th {
background-color: rgba(0, 0, 0, 0.05);
font-weight: 600;
}
.dark .prose th {
background-color: rgba(255, 255, 255, 0.05);
}
.prose hr {
border: 0;
border-top: 2px solid rgba(0, 0, 0, 0.1);
margin: 3em 0;
}
.dark .prose hr {
border-top-color: rgba(255, 255, 255, 0.1);
}
.prose img {
border-radius: 0.5rem;
margin: 2em auto;
max-width: 100%;
height: auto;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{# app/templates/platform/homepage-default.html #}
{# Default platform homepage template with section-based rendering #}
{% extends "public/base.html" %}
{# Import section partials #}
{% from 'platform/sections/_hero.html' import render_hero %}
{% from 'platform/sections/_features.html' import render_features %}
{% from 'platform/sections/_pricing.html' import render_pricing %}
{% from 'platform/sections/_cta.html' import render_cta %}
{% block title %}
{% if page %}{{ page.title }}{% else %}Home{% endif %} - {{ platform.name if platform else 'Multi-Vendor Marketplace' }}
{% endblock %}
{% block meta_description %}
{% if page and page.meta_description %}
{{ page.meta_description }}
{% else %}
Leading multi-vendor marketplace platform. Connect with thousands of vendors and discover millions of products.
{% endif %}
{% endblock %}
{% block content %}
{# Set up language context #}
{% set lang = request.state.language|default("fr") or (platform.default_language if platform else 'fr') %}
{% set default_lang = platform.default_language if platform else 'fr' %}
{# ═══════════════════════════════════════════════════════════════════════════ #}
{# SECTION-BASED RENDERING (when page.sections is configured) #}
{# ═══════════════════════════════════════════════════════════════════════════ #}
{% if page and page.sections %}
{# Hero Section #}
{% if page.sections.hero %}
{{ render_hero(page.sections.hero, lang, default_lang) }}
{% endif %}
{# Features Section #}
{% if page.sections.features %}
{{ render_features(page.sections.features, lang, default_lang) }}
{% endif %}
{# Pricing Section #}
{% if page.sections.pricing %}
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
{% endif %}
{# CTA Section #}
{% if page.sections.cta %}
{{ render_cta(page.sections.cta, lang, default_lang) }}
{% endif %}
{% else %}
{# ═══════════════════════════════════════════════════════════════════════════ #}
{# PLACEHOLDER CONTENT (when sections not configured) #}
{# ═══════════════════════════════════════════════════════════════════════════ #}
<!-- HERO SECTION -->
<section class="gradient-primary text-white py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl md:text-6xl font-bold mb-6">
{{ _('homepage.placeholder.title') or 'Configure Your Homepage' }}
</h1>
<p class="text-xl md:text-2xl mb-8 opacity-90 max-w-3xl mx-auto">
{{ _('homepage.placeholder.subtitle') or 'Use the admin panel to configure homepage sections with multi-language content.' }}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/admin/content-pages"
class="bg-white text-gray-900 px-8 py-4 rounded-xl font-semibold hover:bg-gray-100 transition inline-flex items-center space-x-2">
<span>{{ _('homepage.placeholder.configure_btn') or 'Configure Homepage' }}</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</a>
</div>
</div>
</div>
</section>
<!-- FEATURES SECTION (Placeholder) -->
<section class="py-16 bg-white dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _('homepage.placeholder.features_title') or 'Features Section' }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _('homepage.placeholder.features_subtitle') or 'Configure feature cards in the admin panel' }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{% for i in range(3) %}
<div class="bg-gray-50 dark:bg-gray-700 rounded-xl p-8 text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-400 mb-3">
Feature {{ i + 1 }}
</h3>
<p class="text-gray-400">
Configure this feature card
</p>
</div>
{% endfor %}
</div>
</div>
</section>
<!-- CTA SECTION (Placeholder) -->
<section class="py-16 bg-gray-100 dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-gray-400 mb-4">
{{ _('homepage.placeholder.cta_title') or 'Call to Action' }}
</h2>
<p class="text-lg text-gray-400 mb-8">
{{ _('homepage.placeholder.cta_subtitle') or 'Configure CTA section in the admin panel' }}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<span class="bg-gray-300 text-gray-500 px-6 py-3 rounded-lg font-semibold">
Button 1
</span>
<span class="bg-gray-200 text-gray-500 px-6 py-3 rounded-lg font-semibold">
Button 2
</span>
</div>
</div>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,100 @@
{# app/templates/platform/homepage-minimal.html #}
{# Minimal/clean platform homepage template #}
{% extends "public/base.html" %}
{% block title %}
{% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace
{% endblock %}
{% block content %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- MINIMAL HERO -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-32 bg-white dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{% if page %}
<h1 class="text-5xl md:text-7xl font-bold text-gray-900 dark:text-white mb-8 leading-tight">
{{ page.title }}
</h1>
<div class="text-xl text-gray-600 dark:text-gray-400 mb-12 max-w-2xl mx-auto">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
{% else %}
<h1 class="text-5xl md:text-7xl font-bold text-gray-900 dark:text-white mb-8 leading-tight">
Multi-Vendor<br>Marketplace
</h1>
<p class="text-xl text-gray-600 dark:text-gray-400 mb-12 max-w-2xl mx-auto">
The simplest way to launch your online store and connect with customers worldwide.
</p>
{% endif %}
<a href="/contact"
class="inline-block bg-gray-900 dark:bg-white text-white dark:text-gray-900 px-8 py-4 rounded-lg font-semibold hover:opacity-90 transition text-lg">
Get Started
</a>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- MINIMAL FEATURES -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-24 bg-gray-50 dark:bg-gray-900">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-12">
<div class="text-center">
<div class="text-4xl mb-4"></div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Fast
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">
Lightning-fast performance optimized for conversions
</p>
</div>
<div class="text-center">
<div class="text-4xl mb-4">🔒</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Secure
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">
Enterprise-grade security for your peace of mind
</p>
</div>
<div class="text-center">
<div class="text-4xl mb-4">🎨</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Custom
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">
Fully customizable to match your brand identity
</p>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- MINIMAL CTA -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-24 bg-white dark:bg-gray-800">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
Ready to launch?
</h2>
<p class="text-gray-600 dark:text-gray-400 mb-8">
Join our marketplace today
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact"
class="inline-block bg-gray-900 dark:bg-white text-white dark:text-gray-900 px-6 py-3 rounded-lg font-semibold hover:opacity-90 transition">
Contact Us
</a>
<a href="/about"
class="inline-block border-2 border-gray-900 dark:border-white text-gray-900 dark:text-white px-6 py-3 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition">
Learn More
</a>
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,598 @@
{# app/templates/platform/homepage-modern.html #}
{# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #}
{% extends "public/base.html" %}
{% block title %}
Wizamart - The Back-Office for Letzshop Sellers
{% endblock %}
{% block extra_head %}
<style>
.gradient-lu {
background: linear-gradient(135deg, #00A1DE 0%, #EF3340 100%);
}
.gradient-lu-subtle {
background: linear-gradient(135deg, #f0f9ff 0%, #fef2f2 100%);
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.float-animation {
animation: float 4s ease-in-out infinite;
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
}
</style>
{% endblock %}
{% block content %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- HERO - The Back-Office Letzshop Doesn't Give You -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="relative overflow-hidden bg-gray-900 text-white py-20 md:py-28">
{# Background pattern #}
<div class="absolute inset-0 opacity-10">
<div class="absolute top-0 left-0 w-full h-full" style="background-image: url('data:image/svg+xml,%3Csvg width=\"60\" height=\"60\" viewBox=\"0 0 60 60\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"none\" fill-rule=\"evenodd\"%3E%3Cg fill=\"%23ffffff\" fill-opacity=\"0.4\"%3E%3Cpath d=\"M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E');"></div>
</div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
{# Left column - Content #}
<div>
<div class="inline-flex items-center px-4 py-2 bg-blue-500/20 backdrop-blur-sm rounded-full text-sm font-medium mb-6 border border-blue-400/30">
<span class="mr-2">🇱🇺</span> Built for Luxembourg E-Commerce
</div>
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 leading-tight">
The Back-Office<br>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-cyan-400">
Letzshop Doesn't Give You
</span>
</h1>
<p class="text-xl md:text-2xl text-gray-300 mb-8 leading-relaxed">
Sync orders, manage inventory, generate invoices with correct VAT, and own your customer data. All in one place.
</p>
<div class="flex flex-col sm:flex-row gap-4 mb-8">
<a href="/contact"
class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg hover:shadow-xl">
<span>Start 14-Day Free Trial</span>
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</a>
<a href="#how-it-works"
class="inline-flex items-center justify-center border-2 border-gray-600 text-white px-8 py-4 rounded-xl font-bold hover:bg-white/10 transition-all duration-200">
See How It Works
</a>
</div>
<p class="text-sm text-gray-400">
No credit card required. Setup in 5 minutes. Cancel anytime.
</p>
</div>
{# Right column - Dashboard Preview #}
<div class="hidden lg:block">
<div class="relative float-animation">
<div class="bg-gray-800 rounded-2xl shadow-2xl border border-gray-700 overflow-hidden">
{# Mock dashboard header #}
<div class="bg-gray-900 px-4 py-3 flex items-center gap-2 border-b border-gray-700">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="ml-4 text-gray-400 text-sm">Wizamart Dashboard</span>
</div>
{# Mock dashboard content #}
<div class="p-6 space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-1">Today's Orders</div>
<div class="text-2xl font-bold text-white">24</div>
<div class="text-green-400 text-xs">+12% vs yesterday</div>
</div>
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-1">Revenue</div>
<div class="text-2xl font-bold text-white">EUR 1,847</div>
<div class="text-green-400 text-xs">+8% vs yesterday</div>
</div>
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-1">Low Stock</div>
<div class="text-2xl font-bold text-yellow-400">3</div>
<div class="text-gray-400 text-xs">items need restock</div>
</div>
</div>
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="text-gray-400 text-xs mb-3">Recent Orders from Letzshop</div>
<div class="space-y-2">
<div class="flex justify-between items-center text-sm">
<span class="text-white">#LS-4521</span>
<span class="text-gray-400">Marie D.</span>
<span class="text-green-400">EUR 89.00</span>
<span class="bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded text-xs">Confirmed</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-white">#LS-4520</span>
<span class="text-gray-400">Jean M.</span>
<span class="text-green-400">EUR 156.50</span>
<span class="bg-purple-500/20 text-purple-400 px-2 py-0.5 rounded text-xs">Shipped</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- INTEGRATION BADGE - Works with Letzshop -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-8 bg-gray-50 dark:bg-gray-800 border-y border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row items-center justify-center gap-6 md:gap-12">
<span class="text-gray-500 dark:text-gray-400 font-medium">Official Integration</span>
<div class="flex items-center gap-3 bg-white dark:bg-gray-700 px-6 py-3 rounded-xl shadow-sm">
<span class="text-2xl">🛒</span>
<span class="font-bold text-gray-900 dark:text-white">Letzshop.lu</span>
</div>
<span class="text-gray-500 dark:text-gray-400 text-sm">Connect in 2 minutes</span>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- THE PROBLEM - Pain Points -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Sound Familiar?
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
These are the daily frustrations of Letzshop sellers
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">📋</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Manual Order Entry</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Copy-pasting orders from Letzshop to spreadsheets. Every. Single. Day.</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">📦</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Inventory Chaos</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Stock in Letzshop doesn't match reality. Overselling happens.</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">🧾</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Wrong VAT Invoices</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">EU customers need correct VAT. Your accountant keeps complaining.</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="text-3xl mb-4">👥</div>
<h3 class="font-bold text-gray-900 dark:text-white mb-2">Lost Customers</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">Letzshop owns your customer data. You can't retarget or build loyalty.</p>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- HOW IT WORKS - 4-Step Workflow (Veeqo-style) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section id="how-it-works" class="py-20 gradient-lu-subtle dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<div class="inline-block px-4 py-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-sm font-semibold mb-4">
How It Works
</div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
From Chaos to Control in 4 Steps
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{# Step 1 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">1</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Connect Letzshop</h3>
<p class="text-gray-600 dark:text-gray-400">Enter your Letzshop API credentials. Done in 2 minutes, no technical skills needed.</p>
</div>
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
</div>
{# Step 2 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">2</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Orders Flow In</h3>
<p class="text-gray-600 dark:text-gray-400">Orders sync automatically. Confirm and add tracking directly from Wizamart.</p>
</div>
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
</div>
{# Step 3 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-blue-500 text-white flex items-center justify-center font-bold text-xl mb-6">3</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Generate Invoices</h3>
<p class="text-gray-600 dark:text-gray-400">One click to create compliant PDF invoices with correct VAT for any EU country.</p>
</div>
<div class="hidden lg:block absolute top-1/2 -right-4 w-8 h-0.5 bg-blue-300"></div>
</div>
{# Step 4 #}
<div class="relative">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg h-full">
<div class="w-12 h-12 rounded-full bg-green-500 text-white flex items-center justify-center font-bold text-xl mb-6">4</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Grow Your Business</h3>
<p class="text-gray-600 dark:text-gray-400">Export customers for marketing. Track inventory. Focus on selling, not spreadsheets.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- FEATURES - What You Get -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section id="features" class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<div class="inline-block px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full text-sm font-semibold mb-4">
Features
</div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Everything a Letzshop Seller Needs
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
The operational tools Letzshop doesn't provide
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{# Feature 1: Order Sync #}
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Automatic Order Sync</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Orders from Letzshop appear instantly. Confirm orders and sync tracking numbers back automatically.</p>
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li>Real-time sync</li>
<li>One-click confirmation</li>
<li>Tracking number sync</li>
</ul>
</div>
{# Feature 2: Inventory #}
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-green-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Real Inventory Management</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">One source of truth for all stock. Locations, reservations, and incoming stock tracking.</p>
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li>Product locations (bins)</li>
<li>Stock reservations</li>
<li>Low stock alerts</li>
</ul>
</div>
{# Feature 3: Invoicing #}
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-purple-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Smart VAT Invoicing</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Generate PDF invoices with correct VAT rates. Luxembourg, EU countries, B2B reverse charge.</p>
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li>Luxembourg 17% VAT</li>
<li>EU destination VAT (OSS)</li>
<li>B2B reverse charge</li>
</ul>
</div>
{# Feature 4: Customers #}
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-orange-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Own Your Customers</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">All customer data in your database. Export to Mailchimp for marketing campaigns.</p>
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li>Order history per customer</li>
<li>Lifetime value tracking</li>
<li>CSV export for marketing</li>
</ul>
</div>
{# Feature 5: Team #}
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-cyan-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Team Management</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Invite team members with role-based permissions. Everyone works from one dashboard.</p>
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li>Multiple users</li>
<li>Role-based access</li>
<li>Activity logging</li>
</ul>
</div>
{# Feature 6: Purchase Orders #}
<div class="feature-card bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 transition-all duration-300">
<div class="w-14 h-14 rounded-2xl bg-pink-500 flex items-center justify-center mb-6">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Purchase Orders</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Track incoming stock from suppliers. Know what's on order and when it arrives.</p>
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li>Track supplier orders</li>
<li>Expected arrival dates</li>
<li>Receive and update stock</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- PRICING - 4 Tiers -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section id="pricing" class="py-20 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<div class="inline-block px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full text-sm font-semibold mb-4">
Pricing
</div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Simple, Transparent Pricing
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
No per-order fees. No hidden costs. Flat monthly rate.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{# Essential #}
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Essential</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For solo vendors getting started</p>
<div class="mb-6">
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 49</span>
<span class="text-gray-500 dark:text-gray-400">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
100 orders/month
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
200 products
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Luxembourg VAT invoices
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
1 team member
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
Start Free Trial
</a>
</div>
{# Professional - Highlighted #}
<div class="bg-blue-600 rounded-2xl p-8 shadow-xl relative transform lg:scale-105">
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-orange-500 text-white text-xs font-bold px-3 py-1 rounded-full">
MOST POPULAR
</div>
<h3 class="text-lg font-bold text-white mb-2">Professional</h3>
<p class="text-blue-200 text-sm mb-4">For growing multi-channel sellers</p>
<div class="mb-6">
<span class="text-4xl font-bold text-white">EUR 99</span>
<span class="text-blue-200">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
500 orders/month
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Unlimited products
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>EU VAT invoices</strong>
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Product locations
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Purchase orders
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Customer export
</li>
<li class="flex items-center text-blue-100">
<svg class="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
3 team members
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-white text-blue-600 px-6 py-3 rounded-xl font-bold hover:bg-blue-50 transition-colors">
Start Free Trial
</a>
</div>
{# Business #}
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Business</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For high-volume operations</p>
<div class="mb-6">
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 199</span>
<span class="text-gray-500 dark:text-gray-400">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
2,000 orders/month
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Everything in Professional
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>Analytics dashboard</strong>
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>API access</strong>
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Accounting export
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
10 team members
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
Start Free Trial
</a>
</div>
{# Enterprise #}
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Enterprise</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">For large operations & agencies</p>
<div class="mb-6">
<span class="text-4xl font-bold text-gray-900 dark:text-white">EUR 399+</span>
<span class="text-gray-500 dark:text-gray-400">/month</span>
</div>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Unlimited orders
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Everything in Business
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<strong>White-label option</strong>
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Custom integrations
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
99.9% SLA
</li>
<li class="flex items-center text-gray-600 dark:text-gray-400">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Dedicated support
</li>
</ul>
<a href="/contact" class="block w-full text-center bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white px-6 py-3 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
Contact Sales
</a>
</div>
</div>
<p class="text-center text-gray-500 dark:text-gray-400 mt-8">
All plans include a 14-day free trial. No credit card required.
</p>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- TESTIMONIAL / SOCIAL PROOF -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-20 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="inline-block px-4 py-2 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 rounded-full text-sm font-semibold mb-8">
Built for Luxembourg
</div>
<blockquote class="text-2xl md:text-3xl font-medium text-gray-900 dark:text-white mb-8 leading-relaxed">
"Finally, a tool that understands what Letzshop sellers actually need. No more spreadsheets, no more VAT headaches."
</blockquote>
<div class="flex items-center justify-center gap-4">
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center text-xl">
👩
</div>
<div class="text-left">
<div class="font-semibold text-gray-900 dark:text-white">Marie L.</div>
<div class="text-gray-500 dark:text-gray-400 text-sm">Letzshop Vendor, Luxembourg City</div>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- FINAL CTA -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<section class="py-20 bg-gray-900 text-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-6">
Ready to Take Control of Your Letzshop Business?
</h2>
<p class="text-xl text-gray-300 mb-10">
Join Luxembourg vendors who've stopped fighting spreadsheets and started growing their business.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact"
class="inline-flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-bold transition-all duration-200 shadow-lg">
<span>Start Your 14-Day Free Trial</span>
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</a>
</div>
<p class="mt-8 text-sm text-gray-400">
No credit card required. Setup in 5 minutes. Full Professional features during trial.
</p>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,427 @@
{# app/templates/platform/homepage-wizamart.html #}
{# Wizamart Marketing Homepage - Letzshop OMS Platform #}
{% extends "public/base.html" %}
{% from 'shared/macros/inputs.html' import toggle_switch %}
{% block title %}Wizamart - Order Management for Letzshop Sellers{% endblock %}
{% block meta_description %}Lightweight OMS for Letzshop vendors. Manage orders, inventory, and invoicing. Start your 30-day free trial today.{% endblock %}
{% block content %}
<div x-data="homepageData()" class="bg-gray-50 dark:bg-gray-900">
{# =========================================================================
HERO SECTION
========================================================================= #}
<section class="relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
<div class="text-center">
{# Badge #}
<div class="inline-flex items-center px-4 py-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-full text-indigo-700 dark:text-indigo-300 text-sm font-medium mb-6">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
{{ _("platform.hero.badge", trial_days=trial_days) }}
</div>
{# Headline #}
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold text-gray-900 dark:text-white leading-tight mb-6">
{{ _("platform.hero.title") }}
</h1>
{# Subheadline #}
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-10">
{{ _("platform.hero.subtitle") }}
</p>
{# CTA Buttons #}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/signup"
class="inline-flex items-center justify-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/30 transition-all hover:scale-105">
{{ _("platform.hero.cta_trial") }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</a>
<a href="#find-shop"
class="inline-flex items-center justify-center px-8 py-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-semibold rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-500 transition-all">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
{{ _("platform.hero.cta_find_shop") }}
</a>
</div>
</div>
</div>
{# Background Decoration #}
<div class="absolute inset-0 -z-10 overflow-hidden">
<div class="absolute -top-1/2 -right-1/4 w-96 h-96 bg-indigo-200 dark:bg-indigo-900/20 rounded-full blur-3xl opacity-50"></div>
<div class="absolute -bottom-1/2 -left-1/4 w-96 h-96 bg-purple-200 dark:bg-purple-900/20 rounded-full blur-3xl opacity-50"></div>
</div>
</section>
{# =========================================================================
PRICING SECTION
========================================================================= #}
<section id="pricing" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section Header #}
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.pricing.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("platform.pricing.subtitle", trial_days=trial_days) }}
</p>
{# Billing Toggle #}
<div class="flex justify-center mt-8">
{{ toggle_switch(
model='annual',
left_label=_("platform.pricing.monthly"),
right_label=_("platform.pricing.annual"),
right_badge=_("platform.pricing.save_months")
) }}
</div>
</div>
{# Pricing Cards Grid #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{% for tier in tiers %}
<div class="relative bg-gray-50 dark:bg-gray-900 rounded-2xl p-6 border-2 transition-all hover:shadow-xl
{% if tier.is_popular %}border-indigo-500 shadow-lg{% else %}border-gray-200 dark:border-gray-700{% endif %}">
{# Popular Badge #}
{% if tier.is_popular %}
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
{{ _("platform.pricing.most_popular") }}
</span>
</div>
{% endif %}
{# Tier Name #}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
{# Price #}
<div class="mb-6">
<template x-if="!annual">
<div>
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("platform.pricing.per_month") }}</span>
</div>
</template>
<template x-if="annual">
<div>
{% if tier.price_annual %}
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("platform.pricing.per_month") }}</span>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ tier.price_annual|int }}€ {{ _("platform.pricing.per_year") }}
</div>
{% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("platform.pricing.custom") }}</span>
{% endif %}
</div>
</template>
</div>
{# Features List - Show all features, grey out unavailable #}
<ul class="space-y-2 mb-8 text-sm">
{# Orders #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.orders_per_month %}{{ _("platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("platform.pricing.unlimited_orders") }}{% endif %}
</li>
{# Products #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.products_limit %}{{ _("platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("platform.pricing.unlimited_products") }}{% endif %}
</li>
{# Team Members #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.team_members %}{{ _("platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("platform.pricing.unlimited_team") }}{% endif %}
</li>
{# Letzshop Sync - always included #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{{ _("platform.pricing.letzshop_sync") }}
</li>
{# EU VAT Invoicing #}
<li class="flex items-center {% if 'invoice_eu_vat' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'invoice_eu_vat' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("platform.pricing.eu_vat_invoicing") }}
</li>
{# Analytics Dashboard #}
<li class="flex items-center {% if 'analytics_dashboard' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'analytics_dashboard' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("platform.pricing.analytics_dashboard") }}
</li>
{# API Access #}
<li class="flex items-center {% if 'api_access' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'api_access' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("platform.pricing.api_access") }}
</li>
{# Multi-channel Integration - Enterprise only #}
<li class="flex items-center {% if tier.is_enterprise %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if tier.is_enterprise %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("platform.pricing.multi_channel") }}
</li>
</ul>
{# CTA Button #}
{% if tier.is_enterprise %}
<a href="mailto:sales@wizamart.com?subject=Enterprise%20Plan%20Inquiry"
class="block w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _("platform.pricing.contact_sales") }}
</a>
{% else %}
<a href="/signup?tier={{ tier.code }}"
:href="'/signup?tier={{ tier.code }}&annual=' + annual"
class="block w-full py-3 px-4 font-semibold rounded-xl text-center transition-colors
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-900/50{% endif %}">
{{ _("platform.pricing.start_trial") }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{# =========================================================================
ADD-ONS SECTION
========================================================================= #}
<section id="addons" class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section Header #}
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.addons.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("platform.addons.subtitle") }}
</p>
</div>
{# Add-ons Grid #}
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{% for addon in addons %}
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
{# Icon #}
<div class="w-14 h-14 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center mb-6">
{% if addon.icon == 'globe' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
{% elif addon.icon == 'shield-check' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{% elif addon.icon == 'mail' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
{% endif %}
</div>
{# Name & Description #}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ addon.name }}</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ addon.description }}</p>
{# Price #}
<div class="flex items-baseline">
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ addon.price }}€</span>
<span class="text-gray-500 dark:text-gray-400 ml-1">/{{ addon.billing_period }}</span>
</div>
{# Options for email packages #}
{% if addon.options %}
<div class="mt-4 space-y-2">
{% for opt in addon.options %}
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ opt.quantity }} addresses: {{ opt.price }}€/month
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{# =========================================================================
LETZSHOP VENDOR FINDER
========================================================================= #}
<section id="find-shop" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section Header #}
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.find_shop.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400">
{{ _("platform.find_shop.subtitle") }}
</p>
</div>
{# Search Form #}
<div class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-8 border border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row gap-4">
<input
type="text"
x-model="shopUrl"
placeholder="{{ _('platform.find_shop.placeholder') }}"
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<button
@click="lookupVendor()"
:disabled="loading"
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center">
<template x-if="loading">
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
</template>
{{ _("platform.find_shop.button") }}
</button>
</div>
{# Result #}
<template x-if="vendorResult">
<div class="mt-6 p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<template x-if="vendorResult.found">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="vendorResult.vendor.name"></h3>
<a :href="vendorResult.vendor.letzshop_url" target="_blank" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline" x-text="vendorResult.vendor.letzshop_url"></a>
</div>
<template x-if="!vendorResult.vendor.is_claimed">
<a :href="'/signup?letzshop=' + vendorResult.vendor.slug"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors">
{{ _("platform.find_shop.claim_shop") }}
</a>
</template>
<template x-if="vendorResult.vendor.is_claimed">
<span class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg">
{{ _("platform.find_shop.already_claimed") }}
</span>
</template>
</div>
</template>
<template x-if="!vendorResult.found">
<div class="text-center text-gray-600 dark:text-gray-400">
<p x-text="vendorResult.error || 'Shop not found. Please check your URL and try again.'"></p>
</div>
</template>
</div>
</template>
{# Help Text #}
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ _("platform.find_shop.no_account") }} <a href="https://letzshop.lu" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("platform.find_shop.signup_letzshop") }}</a>{{ _("platform.find_shop.then_connect") }}
</p>
</div>
</div>
</section>
{# =========================================================================
FINAL CTA SECTION
========================================================================= #}
<section class="py-16 lg:py-24 bg-gradient-to-r from-indigo-600 to-purple-600">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
{{ _("platform.cta.title") }}
</h2>
<p class="text-xl text-indigo-100 mb-10">
{{ _("platform.cta.subtitle", trial_days=trial_days) }}
</p>
<a href="/signup"
class="inline-flex items-center px-10 py-4 bg-white text-indigo-600 font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:scale-105">
{{ _("platform.cta.button") }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</a>
</div>
</section>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function homepageData() {
return {
annual: false,
shopUrl: '',
vendorResult: null,
loading: false,
async lookupVendor() {
if (!this.shopUrl.trim()) return;
this.loading = true;
this.vendorResult = null;
try {
const response = await fetch('/api/v1/public/letzshop-vendors/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.shopUrl })
});
this.vendorResult = await response.json();
} catch (error) {
console.error('Lookup error:', error);
this.vendorResult = { found: false, error: 'Failed to lookup. Please try again.' };
} finally {
this.loading = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{# app/templates/platform/sections/_cta.html #}
{# Call-to-action section partial with multi-language support #}
{#
Parameters:
- cta: CTASection object (or dict)
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_cta(cta, lang, default_lang) %}
{% if cta and cta.enabled %}
<section class="py-16 lg:py-24 {% if cta.background_type == 'gradient' %}bg-gradient-to-r from-indigo-600 to-purple-600{% else %}bg-indigo-600{% endif %}">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{# Title #}
{% set title = cta.title.translations.get(lang) or cta.title.translations.get(default_lang) or '' %}
{% if title %}
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
{{ title }}
</h2>
{% endif %}
{# Subtitle #}
{% if cta.subtitle and cta.subtitle.translations %}
{% set subtitle = cta.subtitle.translations.get(lang) or cta.subtitle.translations.get(default_lang) %}
{% if subtitle %}
<p class="text-xl text-indigo-100 mb-10 max-w-2xl mx-auto">
{{ subtitle }}
</p>
{% endif %}
{% endif %}
{# Buttons #}
{% if cta.buttons %}
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
{% for button in cta.buttons %}
{% set btn_text = button.text.translations.get(lang) or button.text.translations.get(default_lang) or '' %}
{% if btn_text and button.url %}
<a href="{{ button.url }}"
class="{% if button.style == 'primary' %}bg-white text-indigo-600 hover:bg-gray-100{% elif button.style == 'secondary' %}bg-indigo-500 text-white hover:bg-indigo-400{% else %}border-2 border-white text-white hover:bg-white/10{% endif %} px-10 py-4 rounded-xl font-bold transition inline-flex items-center space-x-2">
<span>{{ btn_text }}</span>
{% if button.style == 'primary' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
{% endif %}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,72 @@
{# app/templates/platform/sections/_features.html #}
{# Features section partial with multi-language support #}
{#
Parameters:
- features: FeaturesSection object (or dict)
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_features(features, lang, default_lang) %}
{% if features and features.enabled %}
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section header #}
<div class="text-center mb-12">
{% set title = features.title.translations.get(lang) or features.title.translations.get(default_lang) or '' %}
{% if title %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
{% endif %}
{% if features.subtitle and features.subtitle.translations %}
{% set subtitle = features.subtitle.translations.get(lang) or features.subtitle.translations.get(default_lang) %}
{% if subtitle %}
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ subtitle }}
</p>
{% endif %}
{% endif %}
</div>
{# Feature cards #}
{% if features.features %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-{{ [features.features|length, 4]|min }} gap-8">
{% for feature in features.features %}
<div class="card-hover bg-gray-50 dark:bg-gray-700 rounded-xl p-8 text-center">
{# Icon #}
{% if feature.icon %}
<div class="w-16 h-16 mx-auto mb-4 rounded-full gradient-primary flex items-center justify-center">
{# Support for icon names - rendered via Alpine $icon helper or direct SVG #}
{% if feature.icon.startswith('<svg') %}
{{ feature.icon | safe }}
{% else %}
<span x-html="typeof $icon !== 'undefined' ? $icon('{{ feature.icon }}', 'w-8 h-8 text-white') : ''"></span>
{% endif %}
</div>
{% endif %}
{# Title #}
{% set feature_title = feature.title.translations.get(lang) or feature.title.translations.get(default_lang) or '' %}
{% if feature_title %}
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">
{{ feature_title }}
</h3>
{% endif %}
{# Description #}
{% set feature_desc = feature.description.translations.get(lang) or feature.description.translations.get(default_lang) or '' %}
{% if feature_desc %}
<p class="text-gray-600 dark:text-gray-400">
{{ feature_desc }}
</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,71 @@
{# app/templates/platform/sections/_hero.html #}
{# Hero section partial with multi-language support #}
{#
Parameters:
- hero: HeroSection object (or dict)
- lang: Current language code (passed from parent template)
- default_lang: Fallback language (passed from parent template)
#}
{% macro render_hero(hero, lang, default_lang) %}
{% if hero and hero.enabled %}
<section class="gradient-primary text-white py-20 relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
{# Badge #}
{% if hero.badge_text and hero.badge_text.translations %}
{% set badge = hero.badge_text.translations.get(lang) or hero.badge_text.translations.get(default_lang) %}
{% if badge %}
<div class="inline-flex items-center px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full text-white text-sm font-medium mb-6">
{{ badge }}
</div>
{% endif %}
{% endif %}
{# Title #}
{% set title = hero.title.translations.get(lang) or hero.title.translations.get(default_lang) or '' %}
{% if title %}
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold leading-tight mb-6">
{{ title }}
</h1>
{% endif %}
{# Subtitle #}
{% set subtitle = hero.subtitle.translations.get(lang) or hero.subtitle.translations.get(default_lang) or '' %}
{% if subtitle %}
<p class="text-xl md:text-2xl mb-10 opacity-90 max-w-3xl mx-auto">
{{ subtitle }}
</p>
{% endif %}
{# Buttons #}
{% if hero.buttons %}
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
{% for button in hero.buttons %}
{% set btn_text = button.text.translations.get(lang) or button.text.translations.get(default_lang) or '' %}
{% if btn_text and button.url %}
<a href="{{ button.url }}"
class="{% if button.style == 'primary' %}bg-white text-gray-900 hover:bg-gray-100{% elif button.style == 'secondary' %}bg-white/20 text-white hover:bg-white/30{% else %}border-2 border-white text-white hover:bg-white/10{% endif %} px-8 py-4 rounded-xl font-semibold transition inline-flex items-center space-x-2">
<span>{{ btn_text }}</span>
{% if button.style == 'primary' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
{% endif %}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{# Background decorations #}
<div class="absolute top-0 right-0 w-1/3 h-full opacity-10">
<svg viewBox="0 0 200 200" class="w-full h-full">
<circle cx="100" cy="100" r="80" fill="white"/>
</svg>
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,116 @@
{# app/templates/platform/sections/_pricing.html #}
{# Pricing section partial with multi-language support #}
{#
Parameters:
- pricing: PricingSection object (or dict)
- lang: Current language code
- default_lang: Fallback language
- tiers: List of subscription tiers from DB (passed via context)
#}
{% macro render_pricing(pricing, lang, default_lang, tiers) %}
{% if pricing and pricing.enabled %}
<section id="pricing" class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section header #}
<div class="text-center mb-12">
{% set title = pricing.title.translations.get(lang) or pricing.title.translations.get(default_lang) or '' %}
{% if title %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
{% endif %}
{% if pricing.subtitle and pricing.subtitle.translations %}
{% set subtitle = pricing.subtitle.translations.get(lang) or pricing.subtitle.translations.get(default_lang) %}
{% if subtitle %}
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ subtitle }}
</p>
{% endif %}
{% endif %}
</div>
{# Pricing toggle (monthly/annual) #}
{% if pricing.use_subscription_tiers and tiers %}
<div x-data="{ annual: false }" class="space-y-8">
{# Billing toggle #}
<div class="flex justify-center items-center space-x-4">
<span :class="annual ? 'text-gray-400' : 'text-gray-900 dark:text-white font-semibold'">
{{ _('pricing.monthly') or 'Monthly' }}
</span>
<button @click="annual = !annual"
class="relative w-14 h-7 bg-gray-200 dark:bg-gray-700 rounded-full transition-colors"
:class="annual && 'bg-indigo-600 dark:bg-indigo-500'">
<span class="absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow transition-transform"
:class="annual && 'translate-x-7'"></span>
</button>
<span :class="!annual ? 'text-gray-400' : 'text-gray-900 dark:text-white font-semibold'">
{{ _('pricing.annual') or 'Annual' }}
<span class="text-green-500 text-sm ml-1">{{ _('pricing.save_months') or 'Save 2 months!' }}</span>
</span>
</div>
{# Pricing cards #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-{{ [tiers|length, 4]|min }} gap-6">
{% for tier in tiers %}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm hover:shadow-lg transition-shadow p-8 {% if tier.is_popular %}ring-2 ring-indigo-500 relative{% endif %}">
{% if tier.is_popular %}
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
<span class="bg-indigo-500 text-white text-sm font-semibold px-4 py-1 rounded-full">
{{ _('pricing.most_popular') or 'Most Popular' }}
</span>
</div>
{% endif %}
<div class="text-center">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
{{ tier.name }}
</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">
{{ tier.description or '' }}
</p>
{# Price #}
<div class="mb-6">
<span class="text-4xl font-extrabold text-gray-900 dark:text-white"
x-text="annual ? '{{ tier.price_annual or (tier.price_monthly * 10)|int }}' : '{{ tier.price_monthly }}'">
{{ tier.price_monthly }}
</span>
<span class="text-gray-500 dark:text-gray-400">/{{ _('pricing.month') or 'mo' }}</span>
</div>
{# CTA button #}
<a href="/signup?tier={{ tier.code }}"
class="block w-full py-3 px-6 rounded-xl font-semibold transition {% if tier.is_popular %}bg-indigo-600 text-white hover:bg-indigo-700{% else %}bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}">
{{ _('pricing.get_started') or 'Get Started' }}
</a>
</div>
{# Features list #}
{% if tier.features %}
<ul class="mt-8 space-y-3">
{% for feature in tier.features %}
<li class="flex items-start">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span class="text-gray-600 dark:text-gray-400 text-sm">{{ feature }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% else %}
{# Placeholder when no tiers available #}
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
{{ _('pricing.coming_soon') or 'Pricing plans coming soon' }}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,79 @@
{# app/templates/storefront/content-page.html #}
{# Generic CMS content page template #}
{% extends "storefront/base.html" %}
{# Dynamic title from CMS #}
{% block title %}{{ page.title }}{% endblock %}
{# SEO from CMS #}
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
{% block meta_keywords %}{{ page.meta_keywords or vendor.name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
{{ page.title }}
</h1>
{# Optional: Show vendor override badge for debugging #}
{% if page.vendor_id %}
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Custom {{ vendor.name }} version
</span>
</div>
{% endif %}
{# Published date (optional) #}
{% if page.published_at %}
<div class="text-sm text-gray-500 dark:text-gray-400">
Published {{ page.published_at.strftime('%B %d, %Y') }}
</div>
{% endif %}
</div>
{# Content #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
<div class="prose prose-lg dark:prose-invert max-w-none">
{% if page.content_format == 'markdown' %}
{# Markdown content - future enhancement: render with markdown library #}
<div class="markdown-content">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
{% else %}
{# HTML content (default) #}
{{ page.content | safe }}{# sanitized: CMS content #}
{% endif %}
</div>
</div>
{# Last updated timestamp #}
{% if page.updated_at %}
<div class="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Future enhancement: Add any CMS-specific JavaScript here
// For example:
// - Table of contents generation
// - Anchor link handling
// - Image lightbox
// - Copy code blocks
</script>
{% endblock %}

View File

@@ -0,0 +1,126 @@
{# app/templates/vendor/landing-default.html #}
{# standalone #}
{# Default/Minimal Landing Page Template #}
{% extends "shop/base.html" %}
{% block title %}{{ vendor.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or vendor.description or vendor.name }}{% endblock %}
{% block content %}
<div class="min-h-screen">
{# Hero Section - Simple and Clean #}
<section class="relative bg-gradient-to-br from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
{# Logo #}
{% if theme.branding.logo %}
<div class="mb-8">
<img src="{{ theme.branding.logo }}"
alt="{{ vendor.name }}"
class="h-20 w-auto mx-auto">
</div>
{% endif %}
{# Title #}
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
{{ page.title or vendor.name }}
</h1>
{# Tagline #}
{% if vendor.tagline %}
<p class="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8 max-w-3xl mx-auto">
{{ vendor.tagline }}
</p>
{% endif %}
{# CTA Button #}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ base_url }}shop/"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
Browse Our Shop
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% if page.content %}
<a href="#about"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
Learn More
</a>
{% endif %}
</div>
</div>
</div>
</section>
{# Content Section (if provided) #}
{% if page.content %}
<section id="about" class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="prose prose-lg dark:prose-invert max-w-none">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
</div>
</section>
{% endif %}
{# Quick Links Section #}
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
Explore
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}shop/products"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">🛍️</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Shop Products
</h3>
<p class="text-gray-600 dark:text-gray-400">
Browse our complete catalog
</p>
</a>
{% if header_pages %}
{% for page in header_pages[:2] %}
<a href="{{ base_url }}shop/{{ page.slug }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📄</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
{{ page.title }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
{{ page.meta_description or 'Learn more' }}
</p>
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}shop/about"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
About Us
</h3>
<p class="text-gray-600 dark:text-gray-400">
Learn about our story
</p>
</a>
<a href="{{ base_url }}shop/contact"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📧</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Contact
</h3>
<p class="text-gray-600 dark:text-gray-400">
Get in touch with us
</p>
</a>
{% endif %}
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,258 @@
{# app/templates/vendor/landing-full.html #}
{# standalone #}
{# Full Landing Page Template - Maximum Features #}
{% extends "shop/base.html" %}
{% block title %}{{ vendor.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or vendor.description or vendor.name }}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopLayoutData(){% endblock %}
{% block content %}
<div class="min-h-screen">
{# Hero Section - Split Design #}
<section class="relative overflow-hidden bg-gradient-to-br from-primary/10 to-accent/5 dark:from-primary/20 dark:to-accent/10">
<div class="max-w-7xl mx-auto">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center min-h-screen">
{# Left - Content #}
<div class="px-4 sm:px-6 lg:px-8 py-20">
{% if theme.branding.logo %}
<div class="mb-8">
<img src="{{ theme.branding.logo }}"
alt="{{ vendor.name }}"
class="h-16 w-auto">
</div>
{% endif %}
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6 leading-tight">
{{ page.title or vendor.name }}
</h1>
{% if vendor.tagline %}
<p class="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8">
{{ vendor.tagline }}
</p>
{% endif %}
{% if vendor.description %}
<p class="text-lg text-gray-600 dark:text-gray-400 mb-10">
{{ vendor.description }}
</p>
{% endif %}
<div class="flex flex-col sm:flex-row gap-4">
<a href="{{ base_url }}shop/"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg"
style="background-color: var(--color-primary)">
Shop Now
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
<a href="#about"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
Learn More
</a>
</div>
{# Stats/Badges #}
<div class="grid grid-cols-3 gap-8 mt-16 pt-10 border-t border-gray-200 dark:border-gray-700">
<div>
<div class="text-3xl font-bold text-primary mb-2" style="color: var(--color-primary)">100+</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Products</div>
</div>
<div>
<div class="text-3xl font-bold text-primary mb-2" style="color: var(--color-primary)">24/7</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Support</div>
</div>
<div>
<div class="text-3xl font-bold text-primary mb-2" style="color: var(--color-primary)">⭐⭐⭐⭐⭐</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Rated</div>
</div>
</div>
</div>
{# Right - Visual #}
<div class="hidden lg:flex items-center justify-center p-12">
<div class="relative w-full max-w-lg">
{# Decorative Circles #}
<div class="absolute top-0 -left-4 w-72 h-72 bg-primary rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-pulse"></div>
<div class="absolute -bottom-8 -right-4 w-72 h-72 bg-accent rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-pulse" style="animation-delay: 1s;"></div>
{# Image placeholder or icon #}
<div class="relative z-10 bg-white dark:bg-gray-800 rounded-3xl shadow-2xl p-12 text-center">
<div class="text-9xl mb-4">🛍️</div>
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Your Shopping Destination
</p>
</div>
</div>
</div>
</div>
</div>
</section>
{# Features Grid #}
<section class="py-24 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
What We Offer
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Everything you need for an exceptional shopping experience
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div class="p-6 rounded-xl bg-gray-50 dark:bg-gray-800 hover:shadow-lg transition-shadow">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4" style="color: var(--color-primary)">
<span class="w-6 h-6" x-html="$icon('check', 'w-6 h-6')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Premium Quality
</h3>
<p class="text-gray-600 dark:text-gray-400">
Top-tier products carefully selected for you
</p>
</div>
<div class="p-6 rounded-xl bg-gray-50 dark:bg-gray-800 hover:shadow-lg transition-shadow">
<div class="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4" style="color: var(--color-accent)">
<span class="w-6 h-6" x-html="$icon('bolt', 'w-6 h-6')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Fast Shipping
</h3>
<p class="text-gray-600 dark:text-gray-400">
Quick delivery right to your door
</p>
</div>
<div class="p-6 rounded-xl bg-gray-50 dark:bg-gray-800 hover:shadow-lg transition-shadow">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4" style="color: var(--color-primary)">
<span class="w-6 h-6" x-html="$icon('currency-dollar', 'w-6 h-6')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Best Value
</h3>
<p class="text-gray-600 dark:text-gray-400">
Competitive prices and great deals
</p>
</div>
<div class="p-6 rounded-xl bg-gray-50 dark:bg-gray-800 hover:shadow-lg transition-shadow">
<div class="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4" style="color: var(--color-accent)">
<span class="w-6 h-6" x-html="$icon('user-plus', 'w-6 h-6')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
24/7 Support
</h3>
<p class="text-gray-600 dark:text-gray-400">
Always here to help you
</p>
</div>
</div>
</div>
</section>
{# About Section (with content) #}
{% if page.content %}
<section id="about" class="py-24 bg-gray-50 dark:bg-gray-800">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="prose prose-xl dark:prose-invert max-w-none">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
</div>
</section>
{% endif %}
{# Quick Navigation #}
<section class="py-24 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
Explore More
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}shop/products"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">🛍️</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2 group-hover:text-primary transition-colors">
Shop Products
</h3>
<p class="text-gray-600 dark:text-gray-400">
Browse our complete collection
</p>
</div>
<div class="absolute inset-0 bg-primary opacity-0 group-hover:opacity-5 transition-opacity"></div>
</a>
{% if header_pages %}
{% for page in header_pages[:2] %}
<a href="{{ base_url }}shop/{{ page.slug }}"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 dark:from-accent/20 dark:to-accent/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">📄</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2 group-hover:text-accent transition-colors">
{{ page.title }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
{{ page.meta_description or 'Learn more about us' }}
</p>
</div>
<div class="absolute inset-0 bg-accent opacity-0 group-hover:opacity-5 transition-opacity"></div>
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}shop/about"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 dark:from-accent/20 dark:to-accent/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4"></div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2 group-hover:text-accent transition-colors">
About Us
</h3>
<p class="text-gray-600 dark:text-gray-400">
Learn about our story and mission
</p>
</div>
<div class="absolute inset-0 bg-accent opacity-0 group-hover:opacity-5 transition-opacity"></div>
</a>
<a href="{{ base_url }}shop/contact"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">📧</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2 group-hover:text-primary transition-colors">
Contact Us
</h3>
<p class="text-gray-600 dark:text-gray-400">
Get in touch with our team
</p>
</div>
<div class="absolute inset-0 bg-primary opacity-0 group-hover:opacity-5 transition-opacity"></div>
</a>
{% endif %}
</div>
</div>
</section>
{# Final CTA #}
<section class="py-24 bg-gradient-to-r from-primary to-accent text-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl md:text-5xl font-bold mb-6">
Ready to Start Shopping?
</h2>
<p class="text-xl mb-10 opacity-90">
Join thousands of satisfied customers today
</p>
<a href="{{ base_url }}shop/products"
class="inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-primary bg-white hover:bg-gray-50 transition-all transform hover:scale-105 shadow-2xl">
View All Products
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{# app/templates/vendor/landing-minimal.html #}
{# standalone #}
{# Minimal Landing Page Template - Ultra Clean #}
{% extends "shop/base.html" %}
{% block title %}{{ vendor.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or vendor.description or vendor.name }}{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-center py-20">
{# Logo #}
{% if theme.branding.logo %}
<div class="mb-12">
<img src="{{ theme.branding.logo }}"
alt="{{ vendor.name }}"
class="h-24 w-auto mx-auto">
</div>
{% endif %}
{# Title #}
<h1 class="text-5xl md:text-7xl font-bold text-gray-900 dark:text-white mb-8">
{{ page.title or vendor.name }}
</h1>
{# Description/Content #}
{% if page.content %}
<div class="prose prose-lg dark:prose-invert max-w-2xl mx-auto mb-12 text-gray-600 dark:text-gray-300">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
{% elif vendor.description %}
<p class="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-2xl mx-auto">
{{ vendor.description }}
</p>
{% endif %}
{# Single CTA #}
<div>
<a href="{{ base_url }}shop/"
class="inline-flex items-center justify-center px-10 py-5 text-xl font-semibold rounded-full text-white bg-primary hover:bg-primary-dark transition-all transform hover:scale-105 shadow-2xl"
style="background-color: var(--color-primary)">
Enter Shop
<span class="w-6 h-6 ml-3" x-html="$icon('arrow-right', 'w-6 h-6')"></span>
</a>
</div>
{# Optional Links Below #}
{% if header_pages or footer_pages %}
<div class="mt-16 pt-8 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-wrap justify-center gap-6 text-sm">
<a href="{{ base_url }}shop/products" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
Products
</a>
{% for page in (header_pages or footer_pages)[:4] %}
<a href="{{ base_url }}shop/{{ page.slug }}" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
{{ page.title }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,194 @@
{# app/templates/vendor/landing-modern.html #}
{# standalone #}
{# Modern Landing Page Template - Feature Rich #}
{% extends "shop/base.html" %}
{% block title %}{{ vendor.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or vendor.description or vendor.name }}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopLayoutData(){% endblock %}
{% block content %}
<div class="min-h-screen">
{# Hero Section - Full Width with Overlay #}
<section class="relative h-screen flex items-center justify-center bg-gradient-to-br from-primary/20 via-accent/10 to-primary/20 dark:from-primary/30 dark:via-accent/20 dark:to-primary/30">
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{# Logo #}
{% if theme.branding.logo %}
<div class="mb-8 animate-fade-in">
<img src="{{ theme.branding.logo }}"
alt="{{ vendor.name }}"
class="h-24 w-auto mx-auto">
</div>
{% endif %}
{# Main Heading #}
<h1 class="text-5xl md:text-7xl font-bold text-gray-900 dark:text-white mb-6 animate-slide-up">
{{ page.title or vendor.name }}
</h1>
{# Tagline #}
{% if vendor.tagline %}
<p class="text-xl md:text-3xl text-gray-700 dark:text-gray-200 mb-12 max-w-4xl mx-auto animate-slide-up animation-delay-200">
{{ vendor.tagline }}
</p>
{% endif %}
{# CTAs #}
<div class="flex flex-col sm:flex-row gap-6 justify-center animate-fade-in animation-delay-400">
<a href="{{ base_url }}shop/"
class="group inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-white bg-primary hover:bg-primary-dark transition-all transform hover:scale-105 shadow-2xl hover:shadow-3xl"
style="background-color: var(--color-primary)">
<span>Start Shopping</span>
<span class="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
<a href="#features"
class="inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-gray-700 dark:text-gray-200 bg-white/90 dark:bg-gray-800/90 backdrop-blur hover:bg-white dark:hover:bg-gray-800 transition-all border-2 border-gray-200 dark:border-gray-600">
Discover More
</a>
</div>
{# Scroll Indicator #}
<div class="absolute bottom-10 left-1/2 transform -translate-x-1/2 animate-bounce">
<span class="w-6 h-6 text-gray-400" x-html="$icon('arrow-down', 'w-6 h-6')"></span>
</div>
</div>
</section>
{# Features Section #}
<section id="features" class="py-24 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
Why Choose Us
</h2>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{% if vendor.description %}{{ vendor.description }}{% else %}Experience excellence in every purchase{% endif %}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-12">
{# Feature 1 #}
<div class="text-center group">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 text-primary mb-6 group-hover:scale-110 transition-transform"
style="color: var(--color-primary)">
<span class="w-10 h-10" x-html="$icon('check', 'w-10 h-10')"></span>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Quality Products
</h3>
<p class="text-gray-600 dark:text-gray-400">
Carefully curated selection of premium items
</p>
</div>
{# Feature 2 #}
<div class="text-center group">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-accent/10 text-accent mb-6 group-hover:scale-110 transition-transform"
style="color: var(--color-accent)">
<span class="w-10 h-10" x-html="$icon('bolt', 'w-10 h-10')"></span>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Fast Delivery
</h3>
<p class="text-gray-600 dark:text-gray-400">
Quick and reliable shipping to your doorstep
</p>
</div>
{# Feature 3 #}
<div class="text-center group">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary/10 text-primary mb-6 group-hover:scale-110 transition-transform"
style="color: var(--color-primary)">
<span class="w-10 h-10" x-html="$icon('currency-dollar', 'w-10 h-10')"></span>
</div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Best Prices
</h3>
<p class="text-gray-600 dark:text-gray-400">
Competitive pricing with great value
</p>
</div>
</div>
</div>
</section>
{# Content Section (if provided) #}
{% if page.content %}
<section class="py-24 bg-gray-50 dark:bg-gray-800">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="prose prose-xl dark:prose-invert max-w-none">
{{ page.content | safe }}{# sanitized: CMS content #}
</div>
</div>
</section>
{% endif %}
{# CTA Section #}
<section class="py-24 bg-gradient-to-r from-primary to-accent text-white">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-4xl md:text-5xl font-bold mb-6">
Ready to Get Started?
</h2>
<p class="text-xl mb-10 opacity-90">
Explore our collection and find what you're looking for
</p>
<a href="{{ base_url }}shop/products"
class="inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-primary bg-white hover:bg-gray-50 transition-all transform hover:scale-105 shadow-2xl">
Browse Products
<span class="w-5 h-5 ml-2" x-html="$icon('chevron-right', 'w-5 h-5')"></span>
</a>
</div>
</section>
</div>
<style>
/* Animation utilities */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 1s ease-out;
}
.animate-slide-up {
animation: slide-up 0.8s ease-out;
}
.animation-delay-200 {
animation-delay: 0.2s;
animation-fill-mode: backwards;
}
.animation-delay-400 {
animation-delay: 0.4s;
animation-fill-mode: backwards;
}
/* Grid pattern */
.bg-grid-pattern {
background-image:
linear-gradient(to right, currentColor 1px, transparent 1px),
linear-gradient(to bottom, currentColor 1px, transparent 1px);
background-size: 40px 40px;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,445 @@
{# app/templates/vendor/media.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Media Library{% endblock %}
{% block alpine_data %}vendorMedia(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Media Library', subtitle='Upload and manage your images, videos, and documents') %}
<div class="flex items-center gap-4">
{{ refresh_button(loading_var='loading', onclick='loadMedia()', variant='secondary') }}
<button
@click="showUploadModal = true"
class="flex items-center justify-between px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Upload Files
</button>
</div>
{% endcall %}
{{ loading_state('Loading media library...') }}
{{ error_state('Error loading media') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Total Files -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('folder', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Files</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">0</p>
</div>
</div>
<!-- Images -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('photograph', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Images</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.images">0</p>
</div>
</div>
<!-- Videos -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('play', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Videos</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.videos">0</p>
</div>
</div>
<!-- Documents -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Documents</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.documents">0</p>
</div>
</div>
</div>
<!-- Filters -->
<div x-show="!loading" class="bg-white rounded-lg shadow-md dark:bg-gray-800 p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- Search -->
<div class="md:col-span-2">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
<span x-html="$icon('search', 'w-5 h-5')"></span>
</span>
<input
type="text"
x-model="filters.search"
@input.debounce.300ms="loadMedia()"
placeholder="Search files..."
class="w-full pl-10 pr-4 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
>
</div>
</div>
<!-- Type Filter -->
<div>
<select
x-model="filters.type"
@change="loadMedia()"
class="w-full px-4 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
>
<option value="">All Types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="document">Documents</option>
</select>
</div>
<!-- Folder Filter -->
<div>
<select
x-model="filters.folder"
@change="loadMedia()"
class="w-full px-4 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
>
<option value="">All Folders</option>
<option value="general">General</option>
<option value="products">Products</option>
</select>
</div>
</div>
</div>
<!-- Media Grid -->
<div x-show="!loading && !error">
<!-- Empty State -->
<div x-show="media.length === 0" class="bg-white rounded-lg shadow-md dark:bg-gray-800 p-12 text-center">
<div class="text-gray-400 mb-4">
<span x-html="$icon('photograph', 'w-16 h-16 mx-auto')"></span>
</div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">No Media Files Yet</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Upload your first file to get started</p>
<button
@click="showUploadModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
<span x-html="$icon('upload', 'w-4 h-4 inline mr-2')"></span>
Upload Files
</button>
</div>
<!-- Media Grid -->
<div x-show="media.length > 0" class="grid gap-6 md:grid-cols-4 lg:grid-cols-6">
<template x-for="item in media" :key="item.id">
<div
class="bg-white rounded-lg shadow-md dark:bg-gray-800 overflow-hidden cursor-pointer hover:shadow-lg transition-shadow"
@click="selectMedia(item)"
>
<!-- Thumbnail/Preview -->
<div class="aspect-square bg-gray-100 dark:bg-gray-700 relative">
<!-- Image preview -->
<template x-if="item.media_type === 'image'">
<img
:src="item.thumbnail_url || item.file_url"
:alt="item.original_filename"
class="w-full h-full object-cover"
@error="$el.src = '/static/vendor/img/placeholder.svg'"
>
</template>
<!-- Video icon -->
<template x-if="item.media_type === 'video'">
<div class="w-full h-full flex items-center justify-center text-gray-400">
<span x-html="$icon('play', 'w-12 h-12')"></span>
</div>
</template>
<!-- Document icon -->
<template x-if="item.media_type === 'document'">
<div class="w-full h-full flex items-center justify-center text-gray-400">
<span x-html="$icon('document-text', 'w-12 h-12')"></span>
</div>
</template>
<!-- Type badge -->
<div class="absolute top-2 right-2">
<span
class="px-2 py-1 text-xs font-medium rounded"
:class="{
'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100': item.media_type === 'image',
'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100': item.media_type === 'video',
'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100': item.media_type === 'document'
}"
x-text="item.media_type"
></span>
</div>
</div>
<!-- Info -->
<div class="p-3">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate" x-text="item.original_filename"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="formatFileSize(item.file_size)"></p>
</div>
</div>
</template>
</div>
<!-- Pagination -->
<div x-show="pagination.pages > 1" class="mt-6">
{{ pagination('pagination.pages > 1') }}
</div>
</div>
<!-- Upload Modal -->
<div
x-show="showUploadModal"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black bg-opacity-50"
@click.self="showUploadModal = false"
>
<div class="relative w-full max-w-xl mx-4 bg-white rounded-lg shadow-lg dark:bg-gray-800" @click.stop>
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Upload Files</h3>
<button @click="showUploadModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<!-- Modal Body -->
<div class="px-6 py-4">
<!-- Folder Selection -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Upload to Folder</label>
<select
x-model="uploadFolder"
class="w-full px-4 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
<option value="general">General</option>
<option value="products">Products</option>
</select>
</div>
<!-- Drop Zone -->
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="isDragging ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-300 dark:border-gray-600'"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop($event)"
>
<input
type="file"
multiple
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
class="hidden"
x-ref="fileInput"
@change="handleFileSelect($event)"
>
<div class="text-gray-400 mb-4">
<span x-html="$icon('cloud-upload', 'w-12 h-12 mx-auto')"></span>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-2">Drag and drop files here, or</p>
<button
@click="$refs.fileInput.click()"
class="px-4 py-2 text-sm font-medium text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20"
>
Browse Files
</button>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4">
Supported: Images (10MB), Videos (100MB), Documents (20MB)
</p>
</div>
<!-- Upload Progress -->
<div x-show="uploadingFiles.length > 0" class="mt-4 space-y-2">
<template x-for="file in uploadingFiles" :key="file.name">
<div class="flex items-center gap-3 p-2 bg-gray-50 dark:bg-gray-700 rounded">
<div class="flex-shrink-0">
<span x-html="$icon(file.status === 'success' ? 'check-circle' : file.status === 'error' ? 'x-circle' : 'spinner', 'w-5 h-5')"
:class="{
'text-green-500': file.status === 'success',
'text-red-500': file.status === 'error',
'text-gray-400 animate-spin': file.status === 'uploading'
}"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-700 dark:text-gray-200 truncate" x-text="file.name"></p>
<p x-show="file.error" class="text-xs text-red-500" x-text="file.error"></p>
</div>
<div class="text-xs text-gray-500" x-text="file.status === 'uploading' ? 'Uploading...' : file.status"></div>
</div>
</template>
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-end gap-3 px-6 py-4 border-t dark:border-gray-700">
<button
@click="showUploadModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600"
>
Close
</button>
</div>
</div>
</div>
<!-- Media Detail Modal -->
<div
x-show="showDetailModal"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black bg-opacity-50"
@click.self="showDetailModal = false"
>
<div class="relative w-full max-w-2xl mx-4 bg-white rounded-lg shadow-lg dark:bg-gray-800" @click.stop>
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Media Details</h3>
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<!-- Modal Body -->
<div class="px-6 py-4" x-show="selectedMedia">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Preview -->
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden">
<template x-if="selectedMedia?.media_type === 'image'">
<img :src="selectedMedia?.file_url" :alt="selectedMedia?.original_filename" class="w-full h-auto">
</template>
<template x-if="selectedMedia?.media_type !== 'image'">
<div class="aspect-square flex items-center justify-center text-gray-400">
<span x-html="$icon(selectedMedia?.media_type === 'video' ? 'play' : 'document-text', 'w-16 h-16')"></span>
</div>
</template>
</div>
<!-- Details -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Filename</label>
<input
type="text"
x-model="editingMedia.filename"
class="w-full px-3 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Alt Text</label>
<input
type="text"
x-model="editingMedia.alt_text"
placeholder="Describe this image for accessibility"
class="w-full px-3 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Description</label>
<textarea
x-model="editingMedia.description"
rows="2"
class="w-full px-3 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Folder</label>
<select
x-model="editingMedia.folder"
class="w-full px-3 py-2 text-sm border rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
<option value="general">General</option>
<option value="products">Products</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<span class="font-medium">Type:</span>
<span x-text="selectedMedia?.media_type"></span>
</div>
<div>
<span class="font-medium">Size:</span>
<span x-text="formatFileSize(selectedMedia?.file_size)"></span>
</div>
<div x-show="selectedMedia?.width">
<span class="font-medium">Dimensions:</span>
<span x-text="`${selectedMedia?.width}x${selectedMedia?.height}`"></span>
</div>
</div>
<!-- File URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">File URL</label>
<div class="flex gap-2">
<input
type="text"
:value="selectedMedia?.file_url"
readonly
class="flex-1 px-3 py-2 text-sm border rounded-lg bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
<button
@click="copyToClipboard(selectedMedia?.file_url)"
class="px-3 py-2 text-sm border rounded-lg hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
title="Copy URL"
>
<span x-html="$icon('clipboard-copy', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-between px-6 py-4 border-t dark:border-gray-700">
<button
@click="deleteMedia()"
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
:disabled="saving"
>
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
Delete
</button>
<div class="flex gap-3">
<button
@click="showDetailModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
@click="saveMediaDetails()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
:disabled="saving"
>
<span x-show="saving" class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
Save Changes
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('cms_static', path='vendor/js/media.js') }}"></script>
{% endblock %}