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,13 +1,2 @@
# app/modules/analytics/routes/pages/__init__.py
"""
Analytics module page routes.
Provides HTML page endpoints for analytics views:
- Vendor pages: Analytics dashboard for vendors
"""
from app.modules.analytics.routes.pages.vendor import router as vendor_router
# Note: Analytics has no admin pages - admin uses the main dashboard
__all__ = ["vendor_router"]
"""Analytics module page routes."""

View File

@@ -0,0 +1,87 @@
# app/modules/analytics/routes/pages/admin.py
"""
Analytics Admin Page Routes (HTML rendering).
Admin pages for code quality and analytics:
- Code quality dashboard
- Violations list
- Violation detail
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# CODE QUALITY & ARCHITECTURE ROUTES
# ============================================================================
@router.get("/code-quality", response_class=HTMLResponse, include_in_schema=False)
async def admin_code_quality_dashboard(
request: Request,
current_user: User = Depends(
require_menu_access("code-quality", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render code quality dashboard.
Shows architecture violations, trends, and technical debt score.
"""
return templates.TemplateResponse(
"dev_tools/admin/code-quality-dashboard.html",
get_admin_context(request, current_user),
)
@router.get(
"/code-quality/violations", response_class=HTMLResponse, include_in_schema=False
)
async def admin_code_quality_violations(
request: Request,
current_user: User = Depends(
require_menu_access("code-quality", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render violations list page.
Shows all violations with filtering and sorting options.
"""
return templates.TemplateResponse(
"dev_tools/admin/code-quality-violations.html",
get_admin_context(request, current_user),
)
@router.get(
"/code-quality/violations/{violation_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_code_quality_violation_detail(
request: Request,
violation_id: int = Path(..., description="Violation ID"),
current_user: User = Depends(
require_menu_access("code-quality", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render violation detail page.
Shows violation details, code context, assignments, and comments.
"""
return templates.TemplateResponse(
"dev_tools/admin/code-quality-violation-detail.html",
get_admin_context(request, current_user, violation_id=violation_id),
)

View File

@@ -12,7 +12,7 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
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

@@ -18,7 +18,7 @@ from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import AdminOperationException, VendorNotFoundException
from app.modules.tenancy.exceptions import AdminOperationException, VendorNotFoundException
from app.modules.customers.models.customer import Customer
from app.modules.inventory.models import Inventory
from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct

View File

@@ -93,7 +93,7 @@ class UsageService:
Returns current usage, limits, and upgrade recommendations.
"""
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
# Get subscription
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
@@ -151,7 +151,7 @@ class UsageService:
Returns:
LimitCheckData with proceed status and upgrade info
"""
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
if limit_type == "orders":
can_proceed, message = subscription_service.can_create_order(db, vendor_id)

View File

@@ -2,82 +2,294 @@
"""
Billing module exceptions.
Custom exceptions for subscription, billing, and payment operations.
This module provides exception classes for billing operations including:
- Subscription management
- Payment processing (Stripe)
- Feature management
- Tier management
"""
from app.exceptions import BusinessLogicException, ResourceNotFoundException
from app.exceptions.base import (
BusinessLogicException,
ResourceNotFoundException,
ServiceUnavailableException,
ValidationException,
)
__all__ = [
# Base billing exception
"BillingException",
# Subscription exceptions
"SubscriptionNotFoundException",
"NoActiveSubscriptionException",
"SubscriptionNotCancelledException",
"SubscriptionAlreadyCancelledException",
# Tier exceptions
"TierNotFoundException",
"TierNotFoundError",
"TierLimitExceededException",
# Payment exceptions
"PaymentSystemNotConfiguredException",
"StripeNotConfiguredException",
"StripePriceNotConfiguredException",
"PaymentFailedException",
# Webhook exceptions
"InvalidWebhookSignatureException",
"WebhookMissingSignatureException",
"WebhookVerificationException",
# Feature exceptions
"FeatureNotFoundException",
"FeatureNotFoundError",
"FeatureNotAvailableException",
"InvalidFeatureCodesError",
]
# =============================================================================
# Base Billing Exception
# =============================================================================
class BillingException(BusinessLogicException):
"""Base exception for billing module errors."""
pass
def __init__(self, message: str, error_code: str = "BILLING_ERROR", details: dict | None = None):
super().__init__(message=message, error_code=error_code, details=details)
# =============================================================================
# Subscription Exceptions
# =============================================================================
class SubscriptionNotFoundException(ResourceNotFoundException):
"""Raised when a subscription is not found."""
def __init__(self, vendor_id: int):
super().__init__("Subscription", str(vendor_id))
super().__init__(
resource_type="Subscription",
identifier=str(vendor_id),
error_code="SUBSCRIPTION_NOT_FOUND",
)
class NoActiveSubscriptionException(BusinessLogicException):
"""Raised when no active subscription exists for an operation that requires one."""
def __init__(self, message: str = "No active subscription found"):
super().__init__(
message=message,
error_code="NO_ACTIVE_SUBSCRIPTION",
)
class SubscriptionNotCancelledException(BusinessLogicException):
"""Raised when trying to reactivate a subscription that is not cancelled."""
def __init__(self):
super().__init__(
message="Subscription is not cancelled",
error_code="SUBSCRIPTION_NOT_CANCELLED",
)
class SubscriptionAlreadyCancelledException(BusinessLogicException):
"""Raised when trying to cancel an already cancelled subscription."""
def __init__(self):
super().__init__(
message="Subscription is already cancelled",
error_code="SUBSCRIPTION_ALREADY_CANCELLED",
)
# =============================================================================
# Tier Exceptions
# =============================================================================
class TierNotFoundException(ResourceNotFoundException):
"""Raised when a subscription tier is not found."""
def __init__(self, tier_code: str):
super().__init__("SubscriptionTier", tier_code)
super().__init__(
resource_type="SubscriptionTier",
identifier=tier_code,
message=f"Subscription tier '{tier_code}' not found",
error_code="TIER_NOT_FOUND",
)
self.tier_code = tier_code
class TierNotFoundError(ResourceNotFoundException):
"""Subscription tier not found (alternate naming)."""
def __init__(self, tier_code: str):
super().__init__(
resource_type="SubscriptionTier",
identifier=tier_code,
message=f"Tier '{tier_code}' not found",
)
self.tier_code = tier_code
class TierLimitExceededException(BillingException):
"""Raised when a tier limit is exceeded."""
def __init__(self, message: str, limit_type: str, current: int, limit: int):
super().__init__(message)
super().__init__(
message=message,
error_code="TIER_LIMIT_EXCEEDED",
details={
"limit_type": limit_type,
"current": current,
"limit": limit,
},
)
self.limit_type = limit_type
self.current = current
self.limit = limit
# =============================================================================
# Payment Exceptions
# =============================================================================
class PaymentSystemNotConfiguredException(ServiceUnavailableException):
"""Raised when the payment system (Stripe) is not configured."""
def __init__(self):
super().__init__(message="Payment system not configured")
class StripeNotConfiguredException(BillingException):
"""Raised when Stripe is not configured."""
def __init__(self):
super().__init__(
message="Stripe is not configured",
error_code="STRIPE_NOT_CONFIGURED",
)
class StripePriceNotConfiguredException(BusinessLogicException):
"""Raised when Stripe price is not configured for a tier."""
def __init__(self, tier_code: str):
super().__init__(
message=f"Stripe price not configured for tier '{tier_code}'",
error_code="STRIPE_PRICE_NOT_CONFIGURED",
details={"tier_code": tier_code},
)
self.tier_code = tier_code
class PaymentFailedException(BillingException):
"""Raised when a payment fails."""
def __init__(self, message: str, stripe_error: str | None = None):
details = {}
if stripe_error:
details["stripe_error"] = stripe_error
super().__init__(
message=message,
error_code="PAYMENT_FAILED",
details=details if details else None,
)
self.stripe_error = stripe_error
# =============================================================================
# Webhook Exceptions
# =============================================================================
class InvalidWebhookSignatureException(BusinessLogicException):
"""Raised when Stripe webhook signature verification fails."""
def __init__(self, message: str = "Invalid webhook signature"):
super().__init__(
message=message,
error_code="INVALID_WEBHOOK_SIGNATURE",
)
class WebhookMissingSignatureException(BusinessLogicException):
"""Raised when Stripe webhook is missing the signature header."""
def __init__(self):
super().__init__(
message="Missing Stripe-Signature header",
error_code="WEBHOOK_MISSING_SIGNATURE",
)
class WebhookVerificationException(BillingException):
"""Raised when webhook signature verification fails."""
def __init__(self, message: str = "Invalid webhook signature"):
super().__init__(
message=message,
error_code="WEBHOOK_VERIFICATION_FAILED",
)
# =============================================================================
# Feature Exceptions
# =============================================================================
class FeatureNotFoundException(ResourceNotFoundException):
"""Raised when a feature is not found."""
def __init__(self, feature_code: str):
super().__init__(
resource_type="Feature",
identifier=feature_code,
message=f"Feature '{feature_code}' not found",
)
self.feature_code = feature_code
class FeatureNotFoundError(ResourceNotFoundException):
"""Feature not found (alternate naming)."""
def __init__(self, feature_code: str):
super().__init__(
resource_type="Feature",
identifier=feature_code,
message=f"Feature '{feature_code}' not found",
)
self.feature_code = feature_code
class FeatureNotAvailableException(BillingException):
"""Raised when a feature is not available in current tier."""
def __init__(self, feature: str, current_tier: str, required_tier: str):
message = f"Feature '{feature}' requires {required_tier} tier (current: {current_tier})"
super().__init__(message)
super().__init__(
message=message,
error_code="FEATURE_NOT_AVAILABLE",
details={
"feature": feature,
"current_tier": current_tier,
"required_tier": required_tier,
},
)
self.feature = feature
self.current_tier = current_tier
self.required_tier = required_tier
class StripeNotConfiguredException(BillingException):
"""Raised when Stripe is not configured."""
class InvalidFeatureCodesError(ValidationException):
"""Invalid feature codes provided."""
def __init__(self):
super().__init__("Stripe is not configured")
class PaymentFailedException(BillingException):
"""Raised when a payment fails."""
def __init__(self, message: str, stripe_error: str | None = None):
super().__init__(message)
self.stripe_error = stripe_error
class WebhookVerificationException(BillingException):
"""Raised when webhook signature verification fails."""
def __init__(self, message: str = "Invalid webhook signature"):
super().__init__(message)
__all__ = [
"BillingException",
"SubscriptionNotFoundException",
"TierNotFoundException",
"TierLimitExceededException",
"FeatureNotAvailableException",
"StripeNotConfiguredException",
"PaymentFailedException",
"WebhookVerificationException",
]
def __init__(self, invalid_codes: set[str]):
codes_str = ", ".join(sorted(invalid_codes))
super().__init__(
message=f"Invalid feature codes: {codes_str}",
details={"invalid_codes": list(invalid_codes)},
)
self.invalid_codes = invalid_codes

View File

@@ -19,7 +19,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.services.feature_service import feature_service
from app.modules.billing.services.feature_service import feature_service
from models.schema.auth import UserContext
admin_features_router = APIRouter(
@@ -211,7 +211,7 @@ def get_feature(
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
from app.exceptions import FeatureNotFoundError
from app.modules.billing.exceptions import FeatureNotFoundError
raise FeatureNotFoundError(feature_code)

View File

@@ -0,0 +1,247 @@
# app/modules/billing/routes/api/public.py
"""
Public pricing API endpoints.
Provides subscription tier and add-on product information
for the marketing homepage and signup flow.
All endpoints are public (no authentication required).
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.billing.services.platform_pricing_service import platform_pricing_service
from app.modules.billing.models import TierCode
router = APIRouter(prefix="/pricing")
# =============================================================================
# Response Schemas
# =============================================================================
class TierFeature(BaseModel):
"""Feature included in a tier."""
code: str
name: str
description: str | None = None
class TierResponse(BaseModel):
"""Subscription tier details for public display."""
code: str
name: str
description: str | None
price_monthly: float # Price in euros
price_annual: float | None # Price in euros (null for enterprise)
price_monthly_cents: int
price_annual_cents: int | None
orders_per_month: int | None # None = unlimited
products_limit: int | None # None = unlimited
team_members: int | None # None = unlimited
order_history_months: int | None # None = unlimited
features: list[str]
is_popular: bool = False # Highlight as recommended
is_enterprise: bool = False # Contact sales
class Config:
from_attributes = True
class AddOnResponse(BaseModel):
"""Add-on product details for public display."""
code: str
name: str
description: str | None
category: str
price: float # Price in euros
price_cents: int
billing_period: str
quantity_unit: str | None
quantity_value: int | None
class Config:
from_attributes = True
class PricingResponse(BaseModel):
"""Complete pricing information."""
tiers: list[TierResponse]
addons: list[AddOnResponse]
trial_days: int
annual_discount_months: int # e.g., 2 = "2 months free"
# =============================================================================
# Feature Descriptions
# =============================================================================
FEATURE_DESCRIPTIONS = {
"letzshop_sync": "Letzshop Order Sync",
"inventory_basic": "Basic Inventory Management",
"inventory_locations": "Warehouse Locations",
"inventory_purchase_orders": "Purchase Orders",
"invoice_lu": "Luxembourg VAT Invoicing",
"invoice_eu_vat": "EU VAT Invoicing",
"invoice_bulk": "Bulk Invoicing",
"customer_view": "Customer List",
"customer_export": "Customer Export",
"analytics_dashboard": "Analytics Dashboard",
"accounting_export": "Accounting Export",
"api_access": "API Access",
"automation_rules": "Automation Rules",
"team_roles": "Team Roles & Permissions",
"white_label": "White-Label Option",
"multi_vendor": "Multi-Vendor Support",
"custom_integrations": "Custom Integrations",
"sla_guarantee": "SLA Guarantee",
"dedicated_support": "Dedicated Account Manager",
}
# =============================================================================
# Helper Functions
# =============================================================================
def _tier_to_response(tier, is_from_db: bool = True) -> TierResponse:
"""Convert a tier (from DB or hardcoded) to TierResponse."""
if is_from_db:
return TierResponse(
code=tier.code,
name=tier.name,
description=tier.description,
price_monthly=tier.price_monthly_cents / 100,
price_annual=(tier.price_annual_cents / 100) if tier.price_annual_cents else None,
price_monthly_cents=tier.price_monthly_cents,
price_annual_cents=tier.price_annual_cents,
orders_per_month=tier.orders_per_month,
products_limit=tier.products_limit,
team_members=tier.team_members,
order_history_months=tier.order_history_months,
features=tier.features or [],
is_popular=tier.code == TierCode.PROFESSIONAL.value,
is_enterprise=tier.code == TierCode.ENTERPRISE.value,
)
else:
# Hardcoded tier format
tier_enum = tier["tier_enum"]
limits = tier["limits"]
return TierResponse(
code=tier_enum.value,
name=limits["name"],
description=None,
price_monthly=limits["price_monthly_cents"] / 100,
price_annual=(limits["price_annual_cents"] / 100) if limits.get("price_annual_cents") else None,
price_monthly_cents=limits["price_monthly_cents"],
price_annual_cents=limits.get("price_annual_cents"),
orders_per_month=limits.get("orders_per_month"),
products_limit=limits.get("products_limit"),
team_members=limits.get("team_members"),
order_history_months=limits.get("order_history_months"),
features=limits.get("features", []),
is_popular=tier_enum == TierCode.PROFESSIONAL,
is_enterprise=tier_enum == TierCode.ENTERPRISE,
)
def _addon_to_response(addon) -> AddOnResponse:
"""Convert an AddOnProduct to AddOnResponse."""
return AddOnResponse(
code=addon.code,
name=addon.name,
description=addon.description,
category=addon.category,
price=addon.price_cents / 100,
price_cents=addon.price_cents,
billing_period=addon.billing_period,
quantity_unit=addon.quantity_unit,
quantity_value=addon.quantity_value,
)
# =============================================================================
# Endpoints
# =============================================================================
@router.get("/tiers", response_model=list[TierResponse]) # public
def get_tiers(db: Session = Depends(get_db)) -> list[TierResponse]:
"""
Get all public subscription tiers.
Returns tiers from database if available, falls back to hardcoded TIER_LIMITS.
"""
# Try to get from database first
db_tiers = platform_pricing_service.get_public_tiers(db)
if db_tiers:
return [_tier_to_response(tier, is_from_db=True) for tier in db_tiers]
# Fallback to hardcoded tiers
from app.modules.billing.models import TIER_LIMITS
tiers = []
for tier_code in TIER_LIMITS:
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code.value)
if tier_data:
tiers.append(_tier_to_response(tier_data, is_from_db=False))
return tiers
@router.get("/tiers/{tier_code}", response_model=TierResponse) # public
def get_tier(tier_code: str, db: Session = Depends(get_db)) -> TierResponse:
"""Get a specific tier by code."""
# Try database first
tier = platform_pricing_service.get_tier_by_code(db, tier_code)
if tier:
return _tier_to_response(tier, is_from_db=True)
# Fallback to hardcoded
tier_data = platform_pricing_service.get_tier_from_hardcoded(tier_code)
if tier_data:
return _tier_to_response(tier_data, is_from_db=False)
raise ResourceNotFoundException(
resource_type="SubscriptionTier",
identifier=tier_code,
)
@router.get("/addons", response_model=list[AddOnResponse]) # public
def get_addons(db: Session = Depends(get_db)) -> list[AddOnResponse]:
"""
Get all available add-on products.
Returns add-ons from database, or empty list if none configured.
"""
addons = platform_pricing_service.get_active_addons(db)
return [_addon_to_response(addon) for addon in addons]
@router.get("", response_model=PricingResponse) # public
def get_pricing(db: Session = Depends(get_db)) -> PricingResponse:
"""
Get complete pricing information (tiers + add-ons).
This is the main endpoint for the pricing page.
"""
from app.core.config import settings
return PricingResponse(
tiers=get_tiers(db),
addons=get_addons(db),
trial_days=settings.stripe_trial_days,
annual_discount_months=2, # "2 months free" with annual billing
)

View File

@@ -24,8 +24,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.exceptions import FeatureNotFoundError
from app.services.feature_service import feature_service
from app.modules.billing.exceptions import FeatureNotFoundError
from app.modules.billing.services.feature_service import feature_service
from models.schema.auth import UserContext
vendor_features_router = APIRouter(
@@ -134,7 +134,7 @@ def get_available_features(
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj
@@ -175,7 +175,7 @@ def get_features(
vendor_id = current_user.token_vendor_id
# Get subscription for tier info
from app.services.subscription_service import subscription_service
from app.modules.billing.services.subscription_service import subscription_service
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
tier = subscription.tier_obj

View File

@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.services.usage_service import usage_service
from app.modules.analytics.services.usage_service import usage_service
from models.schema.auth import UserContext
vendor_usage_router = APIRouter(

View File

@@ -1,18 +1,2 @@
# app/modules/billing/routes/pages/__init__.py
"""
Billing module page routes (HTML rendering).
Provides Jinja2 template rendering for billing management:
- Admin pages: Subscription tiers, subscriptions list, billing history
- Vendor pages: Billing dashboard, invoices
Note: These routes are placeholders. The actual page rendering
is currently handled by routes in app/api/v1/ and can be migrated here.
"""
__all__ = []
def __getattr__(name: str):
"""Lazy import routers to avoid circular dependencies."""
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
"""Billing module page routes."""

View File

@@ -0,0 +1,80 @@
# app/modules/billing/routes/pages/admin.py
"""
Billing Admin Page Routes (HTML rendering).
Admin pages for billing and subscription management:
- Subscription tiers
- Subscriptions list
- Billing history
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# BILLING & SUBSCRIPTIONS ROUTES
# ============================================================================
@router.get("/subscription-tiers", response_class=HTMLResponse, include_in_schema=False)
async def admin_subscription_tiers_page(
request: Request,
current_user: User = Depends(
require_menu_access("subscription-tiers", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render subscription tiers management page.
Shows all subscription tiers with their limits and pricing.
"""
return templates.TemplateResponse(
"billing/admin/subscription-tiers.html",
get_admin_context(request, current_user),
)
@router.get("/subscriptions", response_class=HTMLResponse, include_in_schema=False)
async def admin_subscriptions_page(
request: Request,
current_user: User = Depends(
require_menu_access("subscriptions", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render vendor subscriptions management page.
Shows all vendor subscriptions with status and usage.
"""
return templates.TemplateResponse(
"billing/admin/subscriptions.html",
get_admin_context(request, current_user),
)
@router.get("/billing-history", response_class=HTMLResponse, include_in_schema=False)
async def admin_billing_history_page(
request: Request,
current_user: User = Depends(
require_menu_access("billing-history", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render billing history page.
Shows invoices and payments across all vendors.
"""
return templates.TemplateResponse(
"billing/admin/billing-history.html",
get_admin_context(request, current_user),
)

View File

@@ -0,0 +1,121 @@
# app/modules/billing/routes/pages/public.py
"""
Billing Public Page Routes (HTML rendering).
Public (unauthenticated) pages for pricing and signup:
- Pricing page
- Signup wizard
- Signup success
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.billing.models import TIER_LIMITS, TierCode
from app.modules.core.utils.page_context import get_public_context
from app.templates_config import templates
router = APIRouter()
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"),
"order_history_months": limits.get("order_history_months"),
"features": limits.get("features", []),
"is_popular": tier_code == TierCode.PROFESSIONAL,
"is_enterprise": tier_code == TierCode.ENTERPRISE,
}
)
return tiers
# ============================================================================
# PRICING PAGE
# ============================================================================
@router.get("/pricing", response_class=HTMLResponse, name="platform_pricing")
async def pricing_page(
request: Request,
db: Session = Depends(get_db),
):
"""
Standalone pricing page with detailed tier comparison.
"""
context = get_public_context(request, db)
context["tiers"] = _get_tiers_data()
context["page_title"] = "Pricing"
return templates.TemplateResponse(
"billing/public/pricing.html",
context,
)
# ============================================================================
# SIGNUP WIZARD
# ============================================================================
@router.get("/signup", response_class=HTMLResponse, name="platform_signup")
async def signup_page(
request: Request,
tier: str | None = None,
annual: bool = False,
db: Session = Depends(get_db),
):
"""
Multi-step signup wizard.
Query params:
- tier: Pre-selected tier code
- annual: Pre-select annual billing
"""
context = get_public_context(request, db)
context["page_title"] = "Start Your Free Trial"
context["selected_tier"] = tier
context["is_annual"] = annual
context["tiers"] = _get_tiers_data()
return templates.TemplateResponse(
"billing/public/signup.html",
context,
)
@router.get(
"/signup/success", response_class=HTMLResponse, name="platform_signup_success"
)
async def signup_success_page(
request: Request,
vendor_code: str | None = None,
db: Session = Depends(get_db),
):
"""
Signup success page.
Shown after successful account creation.
"""
context = get_public_context(request, db)
context["page_title"] = "Welcome to Wizamart!"
context["vendor_code"] = vendor_code
return templates.TemplateResponse(
"billing/public/signup-success.html",
context,
)

View File

@@ -0,0 +1,62 @@
# app/modules/billing/routes/pages/vendor.py
"""
Billing Vendor Page Routes (HTML rendering).
Vendor pages for billing management:
- Billing dashboard
- Invoices
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates
from models.database.user import User
router = APIRouter()
# ============================================================================
# BILLING ROUTES
# ============================================================================
@router.get(
"/{vendor_code}/billing", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_billing_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render billing and subscription management page.
JavaScript loads subscription status, tiers, and invoices via API.
"""
return templates.TemplateResponse(
"billing/vendor/billing.html",
get_vendor_context(request, db, current_user, vendor_code),
)
@router.get(
"/{vendor_code}/invoices", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_invoices_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render invoices management page.
JavaScript loads invoices via API.
"""
return templates.TemplateResponse(
"orders/vendor/invoices.html",
get_vendor_context(request, db, current_user, vendor_code),
)

View File

@@ -27,6 +27,21 @@ from app.modules.billing.services.billing_service import (
NoActiveSubscriptionError,
SubscriptionNotCancelledError,
)
from app.modules.billing.services.feature_service import (
FeatureService,
feature_service,
FeatureInfo,
FeatureUpgradeInfo,
FeatureCode,
)
from app.modules.billing.services.capacity_forecast_service import (
CapacityForecastService,
capacity_forecast_service,
)
from app.modules.billing.services.platform_pricing_service import (
PlatformPricingService,
platform_pricing_service,
)
__all__ = [
"SubscriptionService",
@@ -43,4 +58,13 @@ __all__ = [
"StripePriceNotConfiguredError",
"NoActiveSubscriptionError",
"SubscriptionNotCancelledError",
"FeatureService",
"feature_service",
"FeatureInfo",
"FeatureUpgradeInfo",
"FeatureCode",
"CapacityForecastService",
"capacity_forecast_service",
"PlatformPricingService",
"platform_pricing_service",
]

View File

@@ -162,7 +162,7 @@ class BillingService:
Raises:
VendorNotFoundException from app.exceptions
"""
from app.exceptions import VendorNotFoundException
from app.modules.tenancy.exceptions import VendorNotFoundException
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:

View File

@@ -0,0 +1,321 @@
# app/modules/billing/services/capacity_forecast_service.py
"""
Capacity forecasting service for growth trends and scaling recommendations.
Provides:
- Historical capacity trend analysis
- Growth rate calculations
- Days-until-threshold projections
- Scaling recommendations based on growth patterns
"""
import logging
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.catalog.models import Product
from app.modules.billing.models import (
CapacitySnapshot,
SubscriptionStatus,
VendorSubscription,
)
from models.database.vendor import Vendor, VendorUser
logger = logging.getLogger(__name__)
# Scaling thresholds based on capacity-planning.md
INFRASTRUCTURE_SCALING = [
{"name": "Starter", "max_vendors": 50, "max_products": 10_000, "cost_monthly": 30},
{"name": "Small", "max_vendors": 100, "max_products": 30_000, "cost_monthly": 80},
{"name": "Medium", "max_vendors": 300, "max_products": 100_000, "cost_monthly": 150},
{"name": "Large", "max_vendors": 500, "max_products": 250_000, "cost_monthly": 350},
{"name": "Scale", "max_vendors": 1000, "max_products": 500_000, "cost_monthly": 700},
{"name": "Enterprise", "max_vendors": None, "max_products": None, "cost_monthly": 1500},
]
class CapacityForecastService:
"""Service for capacity forecasting and trend analysis."""
def capture_daily_snapshot(self, db: Session) -> CapacitySnapshot:
"""
Capture a daily snapshot of platform capacity metrics.
Should be called by a daily background job.
"""
from app.modules.core.services.image_service import image_service
from app.modules.monitoring.services.platform_health_service import platform_health_service
now = datetime.now(UTC)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Check if snapshot already exists for today
existing = (
db.query(CapacitySnapshot)
.filter(CapacitySnapshot.snapshot_date == today)
.first()
)
if existing:
logger.info(f"Snapshot already exists for {today}")
return existing
# Gather metrics
total_vendors = db.query(func.count(Vendor.id)).scalar() or 0
active_vendors = (
db.query(func.count(Vendor.id))
.filter(Vendor.is_active == True) # noqa: E712
.scalar()
or 0
)
# Subscription metrics
total_subs = db.query(func.count(VendorSubscription.id)).scalar() or 0
active_subs = (
db.query(func.count(VendorSubscription.id))
.filter(VendorSubscription.status.in_(["active", "trial"]))
.scalar()
or 0
)
trial_vendors = (
db.query(func.count(VendorSubscription.id))
.filter(VendorSubscription.status == SubscriptionStatus.TRIAL.value)
.scalar()
or 0
)
# Resource metrics
total_products = db.query(func.count(Product.id)).scalar() or 0
total_team = (
db.query(func.count(VendorUser.id))
.filter(VendorUser.is_active == True) # noqa: E712
.scalar()
or 0
)
# Orders this month
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
total_orders = sum(
s.orders_this_period
for s in db.query(VendorSubscription).all()
)
# Storage metrics
try:
image_stats = image_service.get_storage_stats()
storage_gb = image_stats.get("total_size_gb", 0)
except Exception:
storage_gb = 0
try:
db_size = platform_health_service._get_database_size(db)
except Exception:
db_size = 0
# Theoretical capacity from subscriptions
capacity = platform_health_service.get_subscription_capacity(db)
theoretical_products = capacity["products"].get("theoretical_limit", 0)
theoretical_orders = capacity["orders_monthly"].get("theoretical_limit", 0)
theoretical_team = capacity["team_members"].get("theoretical_limit", 0)
# Tier distribution
tier_distribution = capacity.get("tier_distribution", {})
# Create snapshot
snapshot = CapacitySnapshot(
snapshot_date=today,
total_vendors=total_vendors,
active_vendors=active_vendors,
trial_vendors=trial_vendors,
total_subscriptions=total_subs,
active_subscriptions=active_subs,
total_products=total_products,
total_orders_month=total_orders,
total_team_members=total_team,
storage_used_gb=Decimal(str(storage_gb)),
db_size_mb=Decimal(str(db_size)),
theoretical_products_limit=theoretical_products,
theoretical_orders_limit=theoretical_orders,
theoretical_team_limit=theoretical_team,
tier_distribution=tier_distribution,
)
db.add(snapshot)
db.flush()
db.refresh(snapshot)
logger.info(f"Captured capacity snapshot for {today}")
return snapshot
def get_growth_trends(self, db: Session, days: int = 30) -> dict:
"""
Calculate growth trends over the specified period.
Returns growth rates and projections for key metrics.
"""
now = datetime.now(UTC)
start_date = now - timedelta(days=days)
# Get snapshots for the period
snapshots = (
db.query(CapacitySnapshot)
.filter(CapacitySnapshot.snapshot_date >= start_date)
.order_by(CapacitySnapshot.snapshot_date)
.all()
)
if len(snapshots) < 2:
return {
"period_days": days,
"snapshots_available": len(snapshots),
"trends": {},
"message": "Insufficient data for trend analysis",
}
first = snapshots[0]
last = snapshots[-1]
period_days = (last.snapshot_date - first.snapshot_date).days or 1
def calc_growth(metric: str) -> dict:
start_val = getattr(first, metric) or 0
end_val = getattr(last, metric) or 0
change = end_val - start_val
if start_val > 0:
growth_rate = (change / start_val) * 100
daily_rate = growth_rate / period_days
monthly_rate = daily_rate * 30
else:
growth_rate = 0 if end_val == 0 else 100
daily_rate = 0
monthly_rate = 0
return {
"start_value": start_val,
"current_value": end_val,
"change": change,
"growth_rate_percent": round(growth_rate, 2),
"daily_growth_rate": round(daily_rate, 3),
"monthly_projection": round(end_val * (1 + monthly_rate / 100), 0),
}
trends = {
"vendors": calc_growth("active_vendors"),
"products": calc_growth("total_products"),
"orders": calc_growth("total_orders_month"),
"team_members": calc_growth("total_team_members"),
"storage_gb": {
"start_value": float(first.storage_used_gb or 0),
"current_value": float(last.storage_used_gb or 0),
"change": float((last.storage_used_gb or 0) - (first.storage_used_gb or 0)),
},
}
return {
"period_days": period_days,
"snapshots_available": len(snapshots),
"start_date": first.snapshot_date.isoformat(),
"end_date": last.snapshot_date.isoformat(),
"trends": trends,
}
def get_scaling_recommendations(self, db: Session) -> list[dict]:
"""
Generate scaling recommendations based on current capacity and growth.
Returns prioritized list of recommendations.
"""
from app.modules.monitoring.services.platform_health_service import platform_health_service
recommendations = []
# Get current capacity
capacity = platform_health_service.get_subscription_capacity(db)
health = platform_health_service.get_full_health_report(db)
trends = self.get_growth_trends(db, days=30)
# Check product capacity
products = capacity["products"]
if products.get("utilization_percent") and products["utilization_percent"] > 80:
recommendations.append({
"category": "capacity",
"severity": "warning",
"title": "Product capacity approaching limit",
"description": f"Currently at {products['utilization_percent']:.0f}% of theoretical product capacity",
"action": "Consider upgrading vendor tiers or adding capacity",
})
# Check infrastructure tier
current_tier = health.get("infrastructure_tier", {})
next_trigger = health.get("next_tier_trigger")
if next_trigger:
recommendations.append({
"category": "infrastructure",
"severity": "info",
"title": f"Current tier: {current_tier.get('name', 'Unknown')}",
"description": f"Next upgrade trigger: {next_trigger}",
"action": "Monitor growth and plan for infrastructure scaling",
})
# Check growth rate
if trends.get("trends"):
vendor_growth = trends["trends"].get("vendors", {})
if vendor_growth.get("monthly_projection", 0) > 0:
monthly_rate = vendor_growth.get("growth_rate_percent", 0)
if monthly_rate > 20:
recommendations.append({
"category": "growth",
"severity": "info",
"title": "High vendor growth rate",
"description": f"Vendor base growing at {monthly_rate:.1f}% over last 30 days",
"action": "Ensure infrastructure can scale to meet demand",
})
# Check storage
storage_percent = health.get("image_storage", {}).get("total_size_gb", 0)
if storage_percent > 800: # 80% of 1TB
recommendations.append({
"category": "storage",
"severity": "warning",
"title": "Storage usage high",
"description": f"Image storage at {storage_percent:.1f} GB",
"action": "Plan for storage expansion or implement cleanup policies",
})
# Sort by severity
severity_order = {"critical": 0, "warning": 1, "info": 2}
recommendations.sort(key=lambda r: severity_order.get(r["severity"], 3))
return recommendations
def get_days_until_threshold(
self, db: Session, metric: str, threshold: int
) -> int | None:
"""
Calculate days until a metric reaches a threshold based on current growth.
Returns None if insufficient data or no growth.
"""
trends = self.get_growth_trends(db, days=30)
if not trends.get("trends") or metric not in trends["trends"]:
return None
metric_data = trends["trends"][metric]
current = metric_data.get("current_value", 0)
daily_rate = metric_data.get("daily_growth_rate", 0)
if daily_rate <= 0 or current >= threshold:
return None
remaining = threshold - current
days = remaining / (current * daily_rate / 100) if current > 0 else None
return int(days) if days else None
# Singleton instance
capacity_forecast_service = CapacityForecastService()

View File

@@ -0,0 +1,590 @@
# app/modules/billing/services/feature_service.py
"""
Feature service for tier-based access control.
Provides:
- Feature availability checking with caching
- Vendor feature listing for API/UI
- Feature metadata for upgrade prompts
- Cache invalidation on subscription changes
Usage:
from app.modules.billing.services.feature_service import feature_service
# Check if vendor has feature
if feature_service.has_feature(db, vendor_id, FeatureCode.ANALYTICS_DASHBOARD):
...
# Get all features available to vendor
features = feature_service.get_vendor_features(db, vendor_id)
# Get feature info for upgrade prompt
info = feature_service.get_feature_upgrade_info(db, "analytics_dashboard")
"""
import logging
import time
from dataclasses import dataclass
from functools import lru_cache
from sqlalchemy.orm import Session, joinedload
from app.modules.billing.exceptions import (
FeatureNotFoundError,
InvalidFeatureCodesError,
TierNotFoundError,
)
from app.modules.billing.models import Feature, FeatureCode
from app.modules.billing.models import SubscriptionTier, VendorSubscription
logger = logging.getLogger(__name__)
@dataclass
class FeatureInfo:
"""Feature information for API responses."""
code: str
name: str
description: str | None
category: str
ui_location: str | None
ui_icon: str | None
ui_route: str | None
ui_badge_text: str | None
is_available: bool
minimum_tier_code: str | None
minimum_tier_name: str | None
@dataclass
class FeatureUpgradeInfo:
"""Information for upgrade prompts."""
feature_code: str
feature_name: str
feature_description: str | None
required_tier_code: str
required_tier_name: str
required_tier_price_monthly_cents: int
class FeatureCache:
"""
In-memory cache for vendor features.
Caches vendor_id -> set of feature codes with TTL.
Invalidated when subscription changes.
"""
def __init__(self, ttl_seconds: int = 300):
self._cache: dict[int, tuple[set[str], float]] = {}
self._ttl = ttl_seconds
def get(self, vendor_id: int) -> set[str] | None:
"""Get cached features for vendor, or None if not cached/expired."""
if vendor_id not in self._cache:
return None
features, timestamp = self._cache[vendor_id]
if time.time() - timestamp > self._ttl:
del self._cache[vendor_id]
return None
return features
def set(self, vendor_id: int, features: set[str]) -> None:
"""Cache features for vendor."""
self._cache[vendor_id] = (features, time.time())
def invalidate(self, vendor_id: int) -> None:
"""Invalidate cache for vendor."""
self._cache.pop(vendor_id, None)
def invalidate_all(self) -> None:
"""Invalidate entire cache."""
self._cache.clear()
class FeatureService:
"""
Service for feature-based access control.
Provides methods to check feature availability and get feature metadata.
Uses in-memory caching with TTL for performance.
"""
def __init__(self):
self._cache = FeatureCache(ttl_seconds=300) # 5 minute cache
self._feature_registry_cache: dict[str, Feature] | None = None
self._feature_registry_timestamp: float = 0
# =========================================================================
# Feature Availability
# =========================================================================
def has_feature(self, db: Session, vendor_id: int, feature_code: str) -> bool:
"""
Check if vendor has access to a specific feature.
Args:
db: Database session
vendor_id: Vendor ID
feature_code: Feature code (use FeatureCode constants)
Returns:
True if vendor has access to the feature
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
return feature_code in vendor_features
def get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
"""
Get set of feature codes available to vendor.
Args:
db: Database session
vendor_id: Vendor ID
Returns:
Set of feature codes the vendor has access to
"""
return self._get_vendor_feature_codes(db, vendor_id)
def _get_vendor_feature_codes(self, db: Session, vendor_id: int) -> set[str]:
"""Internal method with caching."""
# Check cache first
cached = self._cache.get(vendor_id)
if cached is not None:
return cached
# Get subscription with tier relationship
subscription = (
db.query(VendorSubscription)
.options(joinedload(VendorSubscription.tier_obj))
.filter(VendorSubscription.vendor_id == vendor_id)
.first()
)
if not subscription:
logger.warning(f"No subscription found for vendor {vendor_id}")
return set()
# Get features from tier
tier = subscription.tier_obj
if tier and tier.features:
features = set(tier.features)
else:
# Fallback: query tier by code
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == subscription.tier)
.first()
)
features = set(tier.features) if tier and tier.features else set()
# Cache and return
self._cache.set(vendor_id, features)
return features
# =========================================================================
# Feature Listing
# =========================================================================
def get_vendor_features(
self,
db: Session,
vendor_id: int,
category: str | None = None,
include_unavailable: bool = True,
) -> list[FeatureInfo]:
"""
Get all features with availability status for vendor.
Args:
db: Database session
vendor_id: Vendor ID
category: Optional category filter
include_unavailable: Include features not available to vendor
Returns:
List of FeatureInfo with is_available flag
"""
vendor_features = self._get_vendor_feature_codes(db, vendor_id)
# Query all active features
query = db.query(Feature).filter(Feature.is_active == True) # noqa: E712
if category:
query = query.filter(Feature.category == category)
if not include_unavailable:
# Only return features the vendor has
query = query.filter(Feature.code.in_(vendor_features))
features = (
query.options(joinedload(Feature.minimum_tier))
.order_by(Feature.category, Feature.display_order)
.all()
)
result = []
for feature in features:
result.append(
FeatureInfo(
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
is_available=feature.code in vendor_features,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
)
)
return result
def get_available_feature_codes(self, db: Session, vendor_id: int) -> list[str]:
"""
Get list of feature codes available to vendor (for frontend).
Simple list for x-feature directive checks.
"""
return list(self._get_vendor_feature_codes(db, vendor_id))
# =========================================================================
# Feature Metadata
# =========================================================================
def get_feature_by_code(self, db: Session, feature_code: str) -> Feature | None:
"""Get feature by code."""
return (
db.query(Feature)
.options(joinedload(Feature.minimum_tier))
.filter(Feature.code == feature_code)
.first()
)
def get_feature_upgrade_info(
self, db: Session, feature_code: str
) -> FeatureUpgradeInfo | None:
"""
Get upgrade information for a feature.
Used for upgrade prompts when a feature is not available.
"""
feature = self.get_feature_by_code(db, feature_code)
if not feature or not feature.minimum_tier:
return None
tier = feature.minimum_tier
return FeatureUpgradeInfo(
feature_code=feature.code,
feature_name=feature.name,
feature_description=feature.description,
required_tier_code=tier.code,
required_tier_name=tier.name,
required_tier_price_monthly_cents=tier.price_monthly_cents,
)
def get_all_features(
self,
db: Session,
category: str | None = None,
active_only: bool = True,
) -> list[Feature]:
"""Get all features (for admin)."""
query = db.query(Feature).options(joinedload(Feature.minimum_tier))
if active_only:
query = query.filter(Feature.is_active == True) # noqa: E712
if category:
query = query.filter(Feature.category == category)
return query.order_by(Feature.category, Feature.display_order).all()
def get_features_by_tier(self, db: Session, tier_code: str) -> list[str]:
"""Get feature codes for a specific tier."""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier or not tier.features:
return []
return tier.features
# =========================================================================
# Feature Categories
# =========================================================================
def get_categories(self, db: Session) -> list[str]:
"""Get all unique feature categories."""
result = (
db.query(Feature.category)
.filter(Feature.is_active == True) # noqa: E712
.distinct()
.order_by(Feature.category)
.all()
)
return [row[0] for row in result]
def get_features_grouped_by_category(
self, db: Session, vendor_id: int
) -> dict[str, list[FeatureInfo]]:
"""Get features grouped by category with availability."""
features = self.get_vendor_features(db, vendor_id, include_unavailable=True)
grouped: dict[str, list[FeatureInfo]] = {}
for feature in features:
if feature.category not in grouped:
grouped[feature.category] = []
grouped[feature.category].append(feature)
return grouped
# =========================================================================
# Cache Management
# =========================================================================
def invalidate_vendor_cache(self, vendor_id: int) -> None:
"""
Invalidate cache for a specific vendor.
Call this when:
- Vendor's subscription tier changes
- Tier features are updated (for all vendors on that tier)
"""
self._cache.invalidate(vendor_id)
logger.debug(f"Invalidated feature cache for vendor {vendor_id}")
def invalidate_all_cache(self) -> None:
"""
Invalidate entire cache.
Call this when tier features are modified in admin.
"""
self._cache.invalidate_all()
logger.debug("Invalidated all feature caches")
# =========================================================================
# Admin Operations
# =========================================================================
def get_all_tiers_with_features(self, db: Session) -> list[SubscriptionTier]:
"""Get all active tiers with their features for admin."""
return (
db.query(SubscriptionTier)
.filter(SubscriptionTier.is_active == True) # noqa: E712
.order_by(SubscriptionTier.display_order)
.all()
)
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
"""
Get tier by code, raising exception if not found.
Raises:
TierNotFoundError: If tier not found
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise TierNotFoundError(tier_code)
return tier
def get_tier_features_with_details(
self, db: Session, tier_code: str
) -> tuple[SubscriptionTier, list[Feature]]:
"""
Get tier with full feature details.
Returns:
Tuple of (tier, list of Feature objects)
Raises:
TierNotFoundError: If tier not found
"""
tier = self.get_tier_by_code(db, tier_code)
feature_codes = tier.features or []
features = (
db.query(Feature)
.filter(Feature.code.in_(feature_codes))
.order_by(Feature.category, Feature.display_order)
.all()
)
return tier, features
def update_tier_features(
self, db: Session, tier_code: str, feature_codes: list[str]
) -> SubscriptionTier:
"""
Update features for a tier (admin operation).
Args:
db: Database session
tier_code: Tier code
feature_codes: List of feature codes to assign
Returns:
Updated tier
Raises:
TierNotFoundError: If tier not found
InvalidFeatureCodesError: If any feature codes are invalid
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise TierNotFoundError(tier_code)
# Validate feature codes exist
# noqa: SVC-005 - Features are platform-level, not vendor-scoped
valid_codes = {
f.code for f in db.query(Feature.code).filter(Feature.is_active == True).all() # noqa: E712
}
invalid = set(feature_codes) - valid_codes
if invalid:
raise InvalidFeatureCodesError(invalid)
tier.features = feature_codes
# Invalidate all caches since tier features changed
self.invalidate_all_cache()
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
return tier
def update_feature(
self,
db: Session,
feature_code: str,
name: str | None = None,
description: str | None = None,
category: str | None = None,
ui_location: str | None = None,
ui_icon: str | None = None,
ui_route: str | None = None,
ui_badge_text: str | None = None,
minimum_tier_code: str | None = None,
is_active: bool | None = None,
is_visible: bool | None = None,
display_order: int | None = None,
) -> Feature:
"""
Update feature metadata.
Args:
db: Database session
feature_code: Feature code to update
... other optional fields to update
Returns:
Updated feature
Raises:
FeatureNotFoundError: If feature not found
TierNotFoundError: If minimum_tier_code provided but not found
"""
feature = (
db.query(Feature)
.options(joinedload(Feature.minimum_tier))
.filter(Feature.code == feature_code)
.first()
)
if not feature:
raise FeatureNotFoundError(feature_code)
# Update fields if provided
if name is not None:
feature.name = name
if description is not None:
feature.description = description
if category is not None:
feature.category = category
if ui_location is not None:
feature.ui_location = ui_location
if ui_icon is not None:
feature.ui_icon = ui_icon
if ui_route is not None:
feature.ui_route = ui_route
if ui_badge_text is not None:
feature.ui_badge_text = ui_badge_text
if is_active is not None:
feature.is_active = is_active
if is_visible is not None:
feature.is_visible = is_visible
if display_order is not None:
feature.display_order = display_order
# Update minimum tier if provided
if minimum_tier_code is not None:
if minimum_tier_code == "":
feature.minimum_tier_id = None
else:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == minimum_tier_code)
.first()
)
if not tier:
raise TierNotFoundError(minimum_tier_code)
feature.minimum_tier_id = tier.id
logger.info(f"Updated feature {feature_code}")
return feature
def update_feature_minimum_tier(
self, db: Session, feature_code: str, tier_code: str | None
) -> Feature:
"""
Update minimum tier for a feature (for upgrade prompts).
Args:
db: Database session
feature_code: Feature code
tier_code: Tier code or None
Raises:
FeatureNotFoundError: If feature not found
TierNotFoundError: If tier_code provided but not found
"""
feature = db.query(Feature).filter(Feature.code == feature_code).first()
if not feature:
raise FeatureNotFoundError(feature_code)
if tier_code:
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise TierNotFoundError(tier_code)
feature.minimum_tier_id = tier.id
else:
feature.minimum_tier_id = None
logger.info(f"Updated minimum tier for feature {feature_code}: {tier_code}")
return feature
# Singleton instance
feature_service = FeatureService()
# ============================================================================
# Convenience Exports
# ============================================================================
# Re-export FeatureCode for easy imports
__all__ = [
"feature_service",
"FeatureService",
"FeatureInfo",
"FeatureUpgradeInfo",
"FeatureCode",
]

View File

@@ -0,0 +1,94 @@
# app/modules/billing/services/platform_pricing_service.py
"""
Platform pricing service.
Handles database operations for subscription tiers and add-on products.
"""
from sqlalchemy.orm import Session
from app.modules.billing.models import (
AddOnProduct,
SubscriptionTier,
TIER_LIMITS,
TierCode,
)
class PlatformPricingService:
"""Service for handling pricing data operations."""
def get_public_tiers(self, db: Session) -> list[SubscriptionTier]:
"""
Get all public subscription tiers from the database.
Returns:
List of active, public subscription tiers ordered by display_order
"""
return (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.is_active == True,
SubscriptionTier.is_public == True,
)
.order_by(SubscriptionTier.display_order)
.all()
)
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier | None:
"""
Get a specific tier by code from the database.
Args:
db: Database session
tier_code: The tier code to look up
Returns:
SubscriptionTier if found, None otherwise
"""
return (
db.query(SubscriptionTier)
.filter(
SubscriptionTier.code == tier_code,
SubscriptionTier.is_active == True,
)
.first()
)
def get_tier_from_hardcoded(self, tier_code: str) -> dict | None:
"""
Get tier limits from hardcoded TIER_LIMITS.
Args:
tier_code: The tier code to look up
Returns:
Dict with tier limits if valid code, None otherwise
"""
try:
tier_enum = TierCode(tier_code)
limits = TIER_LIMITS[tier_enum]
return {
"tier_enum": tier_enum,
"limits": limits,
}
except ValueError:
return None
def get_active_addons(self, db: Session) -> list[AddOnProduct]:
"""
Get all active add-on products from the database.
Returns:
List of active add-on products ordered by category and display_order
"""
return (
db.query(AddOnProduct)
.filter(AddOnProduct.is_active == True)
.order_by(AddOnProduct.category, AddOnProduct.display_order)
.all()
)
# Singleton instance
platform_pricing_service = PlatformPricingService()

View File

@@ -0,0 +1,207 @@
{# app/templates/admin/billing-history.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_refresh %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Billing History{% endblock %}
{% block alpine_data %}adminBillingHistory(){% endblock %}
{% block content %}
{{ page_header_refresh('Billing History') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Total Invoices -->
<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('document-text', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Invoices</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="pagination.total || 0">0</p>
</div>
</div>
<!-- Paid -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Paid</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="statusCounts.paid || 0">0</p>
</div>
</div>
<!-- Open -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Open</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="statusCounts.open || 0">0</p>
</div>
</div>
<!-- Failed -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Failed</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="statusCounts.uncollectible || 0">0</p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-4 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<!-- Vendor Filter -->
<div class="flex-1 min-w-[200px]">
<select
x-model="filters.vendor_id"
@change="loadInvoices()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">All Vendors</option>
<template x-for="vendor in vendors" :key="vendor.id">
<option :value="vendor.id" x-text="vendor.name"></option>
</template>
</select>
</div>
<!-- Status Filter -->
<select
x-model="filters.status"
@change="loadInvoices()"
class="px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">All Statuses</option>
<option value="paid">Paid</option>
<option value="open">Open</option>
<option value="draft">Draft</option>
<option value="uncollectible">Uncollectible</option>
<option value="void">Void</option>
</select>
<!-- Reset -->
<button
@click="resetFilters()"
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-300 dark:border-gray-600"
>
Reset
</button>
</div>
</div>
<!-- Invoices Table -->
{% call table_wrapper() %}
<table class="w-full whitespace-nowrap">
{% call table_header_custom() %}
{{ th_sortable('invoice_date', 'Date', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3">Invoice #</th>
{{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3">Description</th>
<th class="px-4 py-3 text-right">Amount</th>
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-right">Actions</th>
{% endcall %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading invoices...
</td>
</tr>
</template>
<template x-if="!loading && invoices.length === 0">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No invoices found.
</td>
</tr>
</template>
<template x-for="invoice in invoices" :key="invoice.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
<td class="px-4 py-3 text-sm font-mono" x-text="invoice.invoice_number || '-'"></td>
<td class="px-4 py-3">
<div class="flex items-center">
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="invoice.vendor_name"></p>
<p class="text-xs text-gray-500" x-text="invoice.vendor_code"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm max-w-xs truncate" x-text="invoice.description || 'Subscription'"></td>
<td class="px-4 py-3 text-right">
<span class="font-mono font-semibold" x-text="formatCurrency(invoice.total_cents)"></span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-medium rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': invoice.status === 'open',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': invoice.status === 'draft',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': invoice.status === 'uncollectible',
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': invoice.status === 'void'
}"
x-text="invoice.status.toUpperCase()"></span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<!-- View Invoice -->
<a
x-show="invoice.hosted_invoice_url"
:href="invoice.hosted_invoice_url"
target="_blank"
class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400"
title="View Invoice"
>
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
</a>
<!-- Download PDF -->
<a
x-show="invoice.invoice_pdf_url"
:href="invoice.invoice_pdf_url"
target="_blank"
class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400"
title="Download PDF"
>
<span x-html="$icon('download', 'w-4 h-4')"></span>
</a>
<!-- View Vendor -->
<a
:href="'/admin/vendors/' + invoice.vendor_code"
class="p-2 text-gray-500 hover:text-green-600 dark:hover:text-green-400"
title="View Vendor"
>
<span x-html="$icon('user', 'w-4 h-4')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
</table>
{% endcall %}
<!-- Pagination -->
{{ pagination(show_condition="!loading && pagination.total > 0") }}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('billing_static', path='admin/js/billing-history.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,355 @@
{% extends "admin/base.html" %}
{% block title %}Feature Management{% endblock %}
{% block alpine_data %}featuresPage(){% endblock %}
{% block content %}
<div class="py-6">
<!-- Header -->
<div class="mb-8">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Feature Management
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure which features are available to each subscription tier.
</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
</div>
<!-- Tier Tabs -->
<div x-show="!loading" x-cloak>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="-mb-px flex space-x-8">
<template x-for="tier in tiers" :key="tier.code">
<button
@click="selectedTier = tier.code"
:class="{
'border-purple-500 text-purple-600 dark:text-purple-400': selectedTier === tier.code,
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedTier !== tier.code
}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
<span x-text="tier.name"></span>
<span class="ml-2 px-2 py-0.5 text-xs rounded-full"
:class="selectedTier === tier.code ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="tier.feature_count"></span>
</button>
</template>
</nav>
</div>
<!-- Features Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Available Features (for selected tier) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
Included Features
</h3>
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="`${getSelectedTierFeatures().length} features`"></span>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template x-for="featureCode in getSelectedTierFeatures()" :key="featureCode">
<div class="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="flex items-center">
<span x-html="$icon('check-circle-filled', 'w-5 h-5 text-green-500 mr-3')"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white"
x-text="getFeatureName(featureCode)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400"
x-text="getFeatureCategory(featureCode)"></p>
</div>
</div>
<button
@click="removeFeatureFromTier(featureCode)"
class="text-red-500 hover:text-red-700 p-1">
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</template>
<div x-show="getSelectedTierFeatures().length === 0"
class="text-center py-8 text-gray-500 dark:text-gray-400">
No features assigned to this tier
</div>
</div>
</div>
<!-- All Features (to add) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
Available to Add
</h3>
<select
x-model="categoryFilter"
class="text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-md">
<option value="">All Categories</option>
<template x-for="cat in categories" :key="cat">
<option :value="cat" x-text="cat.charAt(0).toUpperCase() + cat.slice(1)"></option>
</template>
</select>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template x-for="feature in getAvailableFeatures()" :key="feature.code">
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="flex items-center">
<span x-html="$icon('plus', 'w-5 h-5 text-gray-400 mr-3')"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white"
x-text="feature.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400"
x-text="feature.category"></p>
</div>
</div>
<button
@click="addFeatureToTier(feature.code)"
class="text-green-500 hover:text-green-700 p-1">
<span x-html="$icon('plus', 'w-4 h-4')"></span>
</button>
</div>
</template>
<div x-show="getAvailableFeatures().length === 0"
class="text-center py-8 text-gray-500 dark:text-gray-400">
All features are assigned to this tier
</div>
</div>
</div>
</div>
<!-- Save Button -->
<div class="mt-6 flex justify-end">
<button
@click="saveTierFeatures"
:disabled="saving || !hasChanges"
:class="{
'opacity-50 cursor-not-allowed': saving || !hasChanges,
'hover:bg-purple-700': !saving && hasChanges
}"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<span x-show="saving" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
</button>
</div>
<!-- All Features Table -->
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
All Features
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Complete list of all platform features with their minimum tier requirement.
</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Feature
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Category
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Minimum Tier
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="feature in allFeatures" :key="feature.code">
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.code"></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
x-text="feature.category"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': feature.minimum_tier_code === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': feature.minimum_tier_code === 'professional',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': feature.minimum_tier_code === 'business',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': feature.minimum_tier_code === 'enterprise',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': !feature.minimum_tier_code
}"
x-text="feature.minimum_tier_name || 'N/A'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span x-show="feature.is_active"
class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
Active
</span>
<span x-show="!feature.is_active"
class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">
Inactive
</span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function featuresPage() {
return {
...data(),
// State
loading: true,
saving: false,
tiers: [],
allFeatures: [],
categories: [],
selectedTier: 'essential',
categoryFilter: '',
originalTierFeatures: {}, // To track changes
currentTierFeatures: {}, // Current state
// Computed
get hasChanges() {
const original = this.originalTierFeatures[this.selectedTier] || [];
const current = this.currentTierFeatures[this.selectedTier] || [];
return JSON.stringify(original.sort()) !== JSON.stringify(current.sort());
},
// Lifecycle
async init() {
// Call parent init
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
this.currentPage = segments[segments.length - 1] || 'features';
await this.loadData();
},
// Methods
async loadData() {
try {
this.loading = true;
// Load tiers and features in parallel
const [tiersResponse, featuresResponse, categoriesResponse] = await Promise.all([
apiClient.get('/admin/features/tiers'),
apiClient.get('/admin/features'),
apiClient.get('/admin/features/categories'),
]);
this.tiers = tiersResponse.tiers;
this.allFeatures = featuresResponse.features;
this.categories = categoriesResponse.categories;
// Initialize tier features tracking
for (const tier of this.tiers) {
this.originalTierFeatures[tier.code] = [...tier.features];
this.currentTierFeatures[tier.code] = [...tier.features];
}
} catch (error) {
console.error('Failed to load features:', error);
this.showNotification('Failed to load features', 'error');
} finally {
this.loading = false;
}
},
getSelectedTierFeatures() {
return this.currentTierFeatures[this.selectedTier] || [];
},
getAvailableFeatures() {
const tierFeatures = this.getSelectedTierFeatures();
return this.allFeatures.filter(f => {
const notIncluded = !tierFeatures.includes(f.code);
const matchesCategory = !this.categoryFilter || f.category === this.categoryFilter;
return notIncluded && matchesCategory && f.is_active;
});
},
getFeatureName(code) {
const feature = this.allFeatures.find(f => f.code === code);
return feature?.name || code;
},
getFeatureCategory(code) {
const feature = this.allFeatures.find(f => f.code === code);
return feature?.category || 'unknown';
},
addFeatureToTier(featureCode) {
if (!this.currentTierFeatures[this.selectedTier].includes(featureCode)) {
this.currentTierFeatures[this.selectedTier].push(featureCode);
}
},
removeFeatureFromTier(featureCode) {
const index = this.currentTierFeatures[this.selectedTier].indexOf(featureCode);
if (index > -1) {
this.currentTierFeatures[this.selectedTier].splice(index, 1);
}
},
async saveTierFeatures() {
if (!this.hasChanges) return;
try {
this.saving = true;
await apiClient.put(`/admin/features/tiers/${this.selectedTier}/features`, {
feature_codes: this.currentTierFeatures[this.selectedTier]
});
// Update original to match current
this.originalTierFeatures[this.selectedTier] = [...this.currentTierFeatures[this.selectedTier]];
// Update tier in tiers array
const tier = this.tiers.find(t => t.code === this.selectedTier);
if (tier) {
tier.features = [...this.currentTierFeatures[this.selectedTier]];
tier.feature_count = tier.features.length;
}
this.showNotification('Features saved successfully', 'success');
} catch (error) {
console.error('Failed to save features:', error);
this.showNotification('Failed to save features', 'error');
} finally {
this.saving = false;
}
},
showNotification(message, type = 'info') {
// Simple alert for now - could be improved with toast notifications
if (type === 'error') {
alert('Error: ' + message);
} else {
console.log(message);
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,479 @@
{# app/templates/admin/subscription-tiers.html #}
{# noqa: FE-008 - Using raw number inputs for cents/limits in admin tier config modal #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_refresh %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
{% from 'shared/macros/modals.html' import modal_confirm %}
{% block title %}Subscription Tiers{% endblock %}
{% block alpine_data %}adminSubscriptionTiers(){% endblock %}
{% block content %}
{{ page_header_refresh('Subscription Tiers') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<!-- Total Tiers -->
<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('tag', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Tiers</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tiers.length">0</p>
</div>
</div>
<!-- Active Tiers -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Active Tiers</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tiers.filter(t => t.is_active).length">0</p>
</div>
</div>
<!-- Public Tiers -->
<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('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Public Tiers</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tiers.filter(t => t.is_public).length">0</p>
</div>
</div>
<!-- MRR -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Est. MRR</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats ? formatCurrency(stats.mrr_cents) : '-'">-</p>
</div>
</div>
</div>
<!-- Filters & Actions -->
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<label class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" x-model="includeInactive" @change="loadTiers()" class="mr-2 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
Show inactive tiers
</label>
</div>
<button
@click="openCreateModal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Tier
</button>
</div>
<!-- Tiers Table -->
{% call table_wrapper() %}
<table class="w-full whitespace-nowrap">
{% call table_header_custom() %}
<th class="px-4 py-3">#</th>
{{ th_sortable('code', 'Code', 'sortBy', 'sortOrder') }}
{{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-right">Monthly</th>
<th class="px-4 py-3 text-right">Annual</th>
<th class="px-4 py-3 text-center">Orders/Mo</th>
<th class="px-4 py-3 text-center">Products</th>
<th class="px-4 py-3 text-center">Team</th>
<th class="px-4 py-3 text-center">Features</th>
<th class="px-4 py-3 text-center">Status</th>
<th class="px-4 py-3 text-right">Actions</th>
{% endcall %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading">
<tr>
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading tiers...
</td>
</tr>
</template>
<template x-if="!loading && tiers.length === 0">
<tr>
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No subscription tiers found.
</td>
</tr>
</template>
<template x-for="(tier, index) in tiers" :key="tier.id">
<tr class="text-gray-700 dark:text-gray-400" :class="{ 'opacity-50': !tier.is_active }">
<td class="px-4 py-3 text-sm" x-text="tier.display_order"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-medium rounded-full"
:class="{
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200': tier.code === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': tier.code === 'professional',
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': tier.code === 'business',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': tier.code === 'enterprise',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200': !['essential','professional','business','enterprise'].includes(tier.code)
}"
x-text="tier.code.toUpperCase()"></span>
</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100" x-text="tier.name"></td>
<td class="px-4 py-3 text-sm text-right font-mono" x-text="formatCurrency(tier.price_monthly_cents)"></td>
<td class="px-4 py-3 text-sm text-right font-mono" x-text="tier.price_annual_cents ? formatCurrency(tier.price_annual_cents) : '-'"></td>
<td class="px-4 py-3 text-sm text-center" x-text="tier.orders_per_month || 'Unlimited'"></td>
<td class="px-4 py-3 text-sm text-center" x-text="tier.products_limit || 'Unlimited'"></td>
<td class="px-4 py-3 text-sm text-center" x-text="tier.team_members || 'Unlimited'"></td>
<td class="px-4 py-3 text-sm text-center">
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="(tier.features || []).length"></span>
</td>
<td class="px-4 py-3 text-center">
<span x-show="tier.is_active && tier.is_public" class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-200">Active</span>
<span x-show="tier.is_active && !tier.is_public" class="px-2 py-1 text-xs font-medium text-blue-700 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-200">Private</span>
<span x-show="!tier.is_active" class="px-2 py-1 text-xs font-medium text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-300">Inactive</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openFeaturePanel(tier)" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="Edit Features">
<span x-html="$icon('puzzle-piece', 'w-4 h-4')"></span>
</button>
<button @click="openEditModal(tier)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<button
x-show="!tier.is_active"
@click="toggleTierStatus(tier, true)"
class="p-2 text-gray-500 hover:text-green-600 dark:hover:text-green-400"
title="Activate"
>
<span x-html="$icon('check-circle', 'w-4 h-4')"></span>
</button>
<button
x-show="tier.is_active"
@click="toggleTierStatus(tier, false)"
class="p-2 text-gray-500 hover:text-red-600 dark:hover:text-red-400"
title="Deactivate"
>
<span x-html="$icon('x-circle', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
{% endcall %}
<!-- Create/Edit Modal -->
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
<div class="relative w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4" x-text="editingTier ? 'Edit Tier' : 'Create Tier'"></h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Code -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Code</label>
<input
type="text"
x-model="formData.code"
:disabled="editingTier"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white disabled:opacity-50"
placeholder="e.g., premium"
>
</div>
<!-- Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
type="text"
x-model="formData.name"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., Premium Plan"
>
</div>
<!-- Monthly Price -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Monthly Price (cents)</label>
<input
type="number"
x-model.number="formData.price_monthly_cents"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 4900 for 49.00"
>
</div>
<!-- Annual Price -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Annual Price (cents)</label>
<input
type="number"
x-model.number="formData.price_annual_cents"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 49000 for 490.00"
>
</div>
<!-- Orders per Month -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Orders/Month (empty = unlimited)</label>
<input
type="number"
x-model.number="formData.orders_per_month"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 100"
>
</div>
<!-- Products Limit -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Products Limit (empty = unlimited)</label>
<input
type="number"
x-model.number="formData.products_limit"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 200"
>
</div>
<!-- Team Members -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Team Members (empty = unlimited)</label>
<input
type="number"
x-model.number="formData.team_members"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 3"
>
</div>
<!-- Display Order -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display Order</label>
<input
type="number"
x-model.number="formData.display_order"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., 1"
>
</div>
<!-- Stripe Product ID -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stripe Product ID</label>
<input
type="text"
x-model="formData.stripe_product_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="prod_..."
>
</div>
<!-- Stripe Monthly Price ID -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stripe Monthly Price ID</label>
<input
type="text"
x-model="formData.stripe_price_monthly_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="price_..."
>
</div>
<!-- Description -->
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<textarea
x-model="formData.description"
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Brief description of this tier..."
></textarea>
</div>
<!-- Toggles -->
<div class="md:col-span-2 flex gap-6">
<label class="flex items-center">
<input type="checkbox" x-model="formData.is_active" class="mr-2 rounded border-gray-300 dark:border-gray-600">
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
<label class="flex items-center">
<input type="checkbox" x-model="formData.is_public" class="mr-2 rounded border-gray-300 dark:border-gray-600">
<span class="text-sm text-gray-700 dark:text-gray-300">Public (visible to vendors)</span>
</label>
</div>
</div>
<!-- Modal Actions -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="closeModal()"
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-300 dark:border-gray-600"
>
Cancel
</button>
<button
@click="saveTier()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="!saving" x-text="editingTier ? 'Update' : 'Create'"></span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
<!-- Feature Assignment Slide-over Panel -->
<div
x-show="showFeaturePanel"
x-cloak
@keydown.escape.window="closeFeaturePanel()"
class="fixed inset-0 z-50 overflow-hidden"
role="dialog"
aria-modal="true"
>
<!-- Backdrop -->
<div
x-show="showFeaturePanel"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
@click="closeFeaturePanel()"
></div>
<!-- Panel -->
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
<div
x-show="showFeaturePanel"
x-transition:enter="transform transition ease-in-out duration-300"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-300"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="w-screen max-w-lg"
>
<div class="flex h-full flex-col bg-white dark:bg-gray-800 shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Edit Features</h2>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedTierForFeatures?.name"></p>
</div>
<button
@click="closeFeaturePanel()"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Loading State -->
<div x-show="loadingFeatures" class="flex items-center justify-center py-8">
<span x-html="$icon('refresh', 'w-6 h-6 animate-spin text-purple-600')"></span>
<span class="ml-2 text-gray-500 dark:text-gray-400">Loading features...</span>
</div>
<!-- Feature Categories -->
<div x-show="!loadingFeatures" class="space-y-6">
<template x-for="category in categories" :key="category">
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<!-- Category Header -->
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-700/50">
<h3 class="text-sm font-medium text-gray-900 dark:text-white" x-text="formatCategoryName(category)"></h3>
<div class="flex items-center gap-2">
<button
@click="selectAllInCategory(category)"
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
x-show="!allSelectedInCategory(category)"
>
Select all
</button>
<button
@click="deselectAllInCategory(category)"
class="text-xs text-gray-500 dark:text-gray-400 hover:underline"
x-show="allSelectedInCategory(category)"
>
Deselect all
</button>
</div>
</div>
<!-- Features List -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="feature in featuresGrouped[category]" :key="feature.code">
<label class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer">
<input
type="checkbox"
:checked="isFeatureSelected(feature.code)"
@change="toggleFeature(feature.code)"
class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
>
<div class="ml-3">
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description"></div>
</div>
</label>
</template>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="categories.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
No features available.
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="selectedFeatures.length"></span> features selected
</div>
<div class="flex items-center gap-3">
<button
@click="closeFeaturePanel()"
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
@click="saveFeatures()"
:disabled="savingFeatures"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
>
<span x-show="savingFeatures" x-html="$icon('refresh', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="savingFeatures ? 'Saving...' : 'Save Features'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('billing_static', path='admin/js/subscription-tiers.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,329 @@
{# app/templates/admin/subscriptions.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header_refresh %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Vendor Subscriptions{% endblock %}
{% block alpine_data %}adminSubscriptions(){% endblock %}
{% block content %}
{{ page_header_refresh('Vendor Subscriptions') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-6">
<!-- Total -->
<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('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.total_subscriptions || 0">0</p>
</div>
</div>
<!-- Active -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.active_count || 0">0</p>
</div>
</div>
<!-- Trial -->
<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('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Trial</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.trial_count || 0">0</p>
</div>
</div>
<!-- Past Due -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Past Due</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.past_due_count || 0">0</p>
</div>
</div>
<!-- Cancelled -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Cancelled</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.cancelled_count || 0">0</p>
</div>
</div>
<!-- MRR -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-emerald-500 bg-emerald-100 rounded-full dark:text-emerald-100 dark:bg-emerald-500">
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">MRR</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats ? formatCurrency(stats.mrr_cents) : '-'">-</p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-4 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<!-- Search -->
<div class="flex-1 min-w-[200px]">
<input
type="text"
x-model="filters.search"
@input.debounce.300ms="loadSubscriptions()"
placeholder="Search vendor name..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
<!-- Status Filter -->
<select
x-model="filters.status"
@change="loadSubscriptions()"
class="px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="trial">Trial</option>
<option value="past_due">Past Due</option>
<option value="cancelled">Cancelled</option>
<option value="expired">Expired</option>
</select>
<!-- Tier Filter -->
<select
x-model="filters.tier"
@change="loadSubscriptions()"
class="px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">All Tiers</option>
<option value="essential">Essential</option>
<option value="professional">Professional</option>
<option value="business">Business</option>
<option value="enterprise">Enterprise</option>
</select>
<!-- Reset -->
<button
@click="resetFilters()"
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-300 dark:border-gray-600"
>
Reset
</button>
</div>
</div>
<!-- Subscriptions Table -->
{% call table_wrapper() %}
<table class="w-full whitespace-nowrap">
{% call table_header_custom() %}
{{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
{{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
<th class="px-4 py-3 text-center">Orders</th>
<th class="px-4 py-3 text-center">Products</th>
<th class="px-4 py-3 text-center">Team</th>
<th class="px-4 py-3">Period End</th>
<th class="px-4 py-3 text-right">Actions</th>
{% endcall %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="loading">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
Loading subscriptions...
</td>
</tr>
</template>
<template x-if="!loading && subscriptions.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No subscriptions found.
</td>
</tr>
</template>
<template x-for="sub in subscriptions" :key="sub.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3">
<div class="flex items-center">
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.vendor_name"></p>
<p class="text-xs text-gray-500" x-text="sub.vendor_code"></p>
</div>
</div>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-medium rounded-full"
:class="{
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200': sub.tier === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': sub.tier === 'professional',
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': sub.tier === 'business',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': sub.tier === 'enterprise'
}"
x-text="sub.tier.charAt(0).toUpperCase() + sub.tier.slice(1)"></span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-medium rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': sub.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': sub.status === 'trial',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': sub.status === 'past_due',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': sub.status === 'cancelled',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': sub.status === 'expired'
}"
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
</td>
<td class="px-4 py-3 text-center">
<span x-text="sub.orders_this_period"></span>
<span class="text-gray-400">/</span>
<span x-text="sub.orders_limit || '&infin;'"></span>
</td>
<td class="px-4 py-3 text-center">
<span x-text="sub.products_limit || '&infin;'"></span>
</td>
<td class="px-4 py-3 text-center">
<span x-text="sub.team_members_limit || '&infin;'"></span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(sub.period_end)"></td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openEditModal(sub)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<a :href="'/admin/vendors/' + sub.vendor_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Vendor">
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
</table>
{% endcall %}
<!-- Pagination -->
{{ pagination(show_condition="!loading && pagination.total > 0") }}
<!-- Edit Modal -->
{# noqa: FE-004 - Inline modal required for complex subscription edit form #}
{# noqa: FE-008 - Using raw number inputs for custom limit overrides #}
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
<div class="relative w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Subscription</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Vendor: ' + (editingSub?.vendor_name || '')"></p>
<div class="space-y-4">
<!-- Tier -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tier</label>
<select
x-model="formData.tier"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="essential">Essential</option>
<option value="professional">Professional</option>
<option value="business">Business</option>
<option value="enterprise">Enterprise</option>
</select>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select
x-model="formData.status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="trial">Trial</option>
<option value="active">Active</option>
<option value="past_due">Past Due</option>
<option value="cancelled">Cancelled</option>
<option value="expired">Expired</option>
</select>
</div>
<!-- Custom Limits Section -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Custom Limit Overrides</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Leave empty to use tier defaults</p>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Orders/Month</label>
<input
type="number"
x-model.number="formData.custom_orders_limit"
placeholder="Tier default"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Products</label>
<input
type="number"
x-model.number="formData.custom_products_limit"
placeholder="Tier default"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Team Members</label>
<input
type="number"
x-model.number="formData.custom_team_limit"
placeholder="Tier default"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
</div>
</div>
</div>
</div>
<!-- Modal Actions -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="closeModal()"
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-300 dark:border-gray-600"
>
Cancel
</button>
<button
@click="saveSubscription()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="!saving">Save Changes</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('billing_static', path='admin/js/subscriptions.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,119 @@
{# app/templates/platform/pricing.html #}
{# Standalone Pricing Page #}
{% extends "public/base.html" %}
{% block title %}{{ _("platform.pricing.title") }} - Wizamart{% endblock %}
{% block content %}
<div x-data="{ annual: false }" class="py-16 lg:py-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Header #}
<div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.pricing.title") }}
</h1>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("platform.pricing.trial_note", trial_days=trial_days) }}
</p>
{# Billing Toggle #}
<div class="flex items-center justify-center mt-8 space-x-4">
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': !annual }">{{ _("platform.pricing.monthly") }}</span>
<button @click="annual = !annual"
class="relative w-14 h-7 rounded-full transition-colors"
:class="annual ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'">
<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="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': annual }">
{{ _("platform.pricing.annual") }}
<span class="text-green-600 text-sm font-medium ml-1">{{ _("platform.pricing.save_months") }}</span>
</span>
</div>
</div>
{# Pricing Cards #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{% for tier in tiers %}
<div class="relative bg-white dark:bg-gray-800 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 %}">
{% 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.recommended") }}</span>
</div>
{% endif %}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
<div class="mb-6">
<template x-if="!annual">
<div>
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly }}</span>
<span class="text-gray-500">{{ _("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">{{ _("platform.pricing.per_month") }}</span>
<div class="text-sm text-gray-500">{{ tier.price_annual }}{{ _("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>
<ul class="space-y-3 mb-8 text-sm">
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" 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>
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" 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>
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" 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>
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" 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>
</ul>
{% if tier.is_enterprise %}
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
{{ _("platform.pricing.contact_sales") }}
</a>
{% else %}
<a :href="'/signup?tier={{ tier.code }}&annual=' + annual"
class="block w-full py-3 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 text-indigo-700 hover:bg-indigo-200{% endif %}">
{{ _("platform.pricing.start_trial") }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
{# Back to Home #}
<div class="text-center mt-12">
<a href="/" class="text-indigo-600 dark:text-indigo-400 hover:underline">
&larr; {{ _("platform.pricing.back_home") }}
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{# app/templates/platform/signup-success.html #}
{# Signup Success Page #}
{% extends "public/base.html" %}
{% block title %}{{ _("platform.success.title") }}{% endblock %}
{% block content %}
<div class="min-h-screen py-16 bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div class="max-w-lg mx-auto px-4 sm:px-6 lg:px-8 text-center">
{# Success Icon #}
<div class="w-24 h-24 mx-auto mb-8 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<svg class="w-12 h-12 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
{# Welcome Message #}
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.success.title") }}
</h1>
<p class="text-xl text-gray-600 dark:text-gray-400 mb-8">
{{ _("platform.success.subtitle", trial_days=trial_days) }}
</p>
{# Next Steps #}
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-left mb-8">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("platform.success.what_next") }}</h2>
<ul class="space-y-4">
<li class="flex items-start">
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">1</span>
</div>
<span class="ml-3 text-gray-700 dark:text-gray-300">
<strong>{{ _("platform.success.step_connect") }}</strong> {{ _("platform.success.step_connect_desc") }}
</span>
</li>
<li class="flex items-start">
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">2</span>
</div>
<span class="ml-3 text-gray-700 dark:text-gray-300">
<strong>{{ _("platform.success.step_invoicing") }}</strong> {{ _("platform.success.step_invoicing_desc") }}
</span>
</li>
<li class="flex items-start">
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">3</span>
</div>
<span class="ml-3 text-gray-700 dark:text-gray-300">
<strong>{{ _("platform.success.step_products") }}</strong> {{ _("platform.success.step_products_desc") }}
</span>
</li>
</ul>
</div>
{# CTA Button #}
{% if vendor_code %}
<a href="/vendor/{{ vendor_code }}/dashboard"
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all hover:scale-105">
{{ _("platform.success.go_to_dashboard") }}
<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>
{% else %}
<a href="/admin/login"
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all">
{{ _("platform.success.login_dashboard") }}
</a>
{% endif %}
{# Support Link #}
<p class="mt-8 text-gray-500 dark:text-gray-400">
{{ _("platform.success.need_help") }}
<a href="/contact" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("platform.success.contact_support") }}</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,534 @@
{# app/templates/platform/signup.html #}
{# Multi-step Signup Wizard #}
{% extends "public/base.html" %}
{% block title %}Start Your Free Trial - Wizamart{% endblock %}
{% block extra_head %}
{# Stripe.js for payment #}
<script src="https://js.stripe.com/v3/"></script>
{% endblock %}
{% block content %}
<div x-data="signupWizard()" class="min-h-screen py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
{# Progress Steps #}
<div class="mb-12">
<div class="flex items-center justify-between">
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
<template x-if="currentStep > index + 1">
<svg class="w-5 h-5" 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>
</template>
<template x-if="currentStep <= index + 1">
<span x-text="index + 1"></span>
</template>
</div>
<span class="ml-2 text-sm font-medium hidden sm:inline"
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
x-text="stepName"></span>
<template x-if="index < 3">
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-full bg-indigo-600 rounded transition-all"
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
</div>
</template>
</div>
</template>
</div>
</div>
{# Form Card #}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{# ===============================================================
STEP 1: SELECT PLAN
=============================================================== #}
<div x-show="currentStep === 1" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
{# Billing Toggle #}
<div class="flex items-center justify-center mb-8 space-x-4">
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
<button @click="isAnnual = !isAnnual"
class="relative w-12 h-6 rounded-full transition-colors"
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
<span class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform"
:class="isAnnual ? 'translate-x-6' : ''"></span>
</button>
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
Annual <span class="text-green-600 text-xs">Save 17%</span>
</span>
</div>
{# Tier Options #}
<div class="space-y-4">
{% for tier in tiers %}
{% if not tier.is_enterprise %}
<label class="block">
<input type="radio" name="tier" value="{{ tier.code }}"
x-model="selectedTier" class="hidden peer"/>
<div class="p-4 border-2 rounded-xl cursor-pointer transition-all
peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:peer-checked:bg-indigo-900/20
border-gray-200 dark:border-gray-700 hover:border-gray-300">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
<p class="text-sm text-gray-500">
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/mo{% else %}Unlimited{% endif %}
&bull;
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
</p>
</div>
<div class="text-right">
<template x-if="!isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
</template>
<template x-if="isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
</template>
</div>
</div>
</div>
</label>
{% endif %}
{% endfor %}
</div>
{# Free Trial Note #}
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-sm text-green-800 dark:text-green-300">
<strong>{{ trial_days }}-day free trial.</strong>
We'll collect your payment info, but you won't be charged until the trial ends.
</p>
</div>
<button @click="startSignup()"
:disabled="!selectedTier || loading"
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
Continue
</button>
</div>
{# ===============================================================
STEP 2: CLAIM LETZSHOP SHOP (Optional)
=============================================================== #}
<div x-show="currentStep === 2" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Connect Your Letzshop Shop</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
<div class="space-y-4">
<input
type="text"
x-model="letzshopUrl"
placeholder="letzshop.lu/vendors/your-shop"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
/>
<template x-if="letzshopVendor">
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-green-800 dark:text-green-300">
Found: <strong x-text="letzshopVendor.name"></strong>
</p>
</div>
</template>
<template x-if="letzshopError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="letzshopError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 1"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="claimVendor()"
:disabled="loading"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
</button>
</div>
</div>
{# ===============================================================
STEP 3: CREATE ACCOUNT
=============================================================== #}
<div x-show="currentStep === 3" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
<span class="text-red-500">*</span> Required fields
</p>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.firstName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.lastName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Company Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.companyName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="account.email" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-red-500">*</span>
</label>
<input type="password" x-model="account.password" required minlength="8"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<template x-if="accountError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 2"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createAccount()"
:disabled="loading || !isAccountValid()"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue to Payment
</button>
</div>
</div>
{# ===============================================================
STEP 4: PAYMENT
=============================================================== #}
<div x-show="currentStep === 4" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
{# Stripe Card Element #}
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 3"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="submitPayment()"
:disabled="loading || paymentProcessing"
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
<template x-if="paymentProcessing">
<span>Processing...</span>
</template>
<template x-if="!paymentProcessing">
<span>Start Free Trial</span>
</template>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function signupWizard() {
return {
currentStep: 1,
loading: false,
sessionId: null,
// Step 1: Plan
selectedTier: '{{ selected_tier or "professional" }}',
isAnnual: {{ 'true' if is_annual else 'false' }},
// Step 2: Letzshop
letzshopUrl: '',
letzshopVendor: null,
letzshopError: null,
// Step 3: Account
account: {
firstName: '',
lastName: '',
companyName: '',
email: '',
password: ''
},
accountError: null,
// Step 4: Payment
stripe: null,
cardElement: null,
paymentProcessing: false,
clientSecret: null,
init() {
// Check URL params for pre-selection
const params = new URLSearchParams(window.location.search);
if (params.get('tier')) {
this.selectedTier = params.get('tier');
}
if (params.get('annual') === 'true') {
this.isAnnual = true;
}
if (params.get('letzshop')) {
this.letzshopUrl = params.get('letzshop');
}
// Initialize Stripe when we get to step 4
this.$watch('currentStep', (step) => {
if (step === 4) {
this.initStripe();
}
});
},
async startSignup() {
this.loading = true;
try {
const response = await fetch('/api/v1/public/signup/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier_code: this.selectedTier,
is_annual: this.isAnnual
})
});
const data = await response.json();
if (response.ok) {
this.sessionId = data.session_id;
this.currentStep = 2;
} else {
alert(data.detail || 'Failed to start signup');
}
} catch (error) {
console.error('Error:', error);
alert('Failed to start signup. Please try again.');
} finally {
this.loading = false;
}
},
async claimVendor() {
if (this.letzshopUrl.trim()) {
this.loading = true;
this.letzshopError = null;
try {
// First lookup the vendor
const lookupResponse = await fetch('/api/v1/public/letzshop-vendors/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.letzshopUrl })
});
const lookupData = await lookupResponse.json();
if (lookupData.found && !lookupData.vendor.is_claimed) {
this.letzshopVendor = lookupData.vendor;
// Claim the vendor
const claimResponse = await fetch('/api/v1/public/signup/claim-vendor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
letzshop_slug: lookupData.vendor.slug
})
});
if (claimResponse.ok) {
const claimData = await claimResponse.json();
this.account.companyName = claimData.vendor_name || '';
this.currentStep = 3;
} else {
const error = await claimResponse.json();
this.letzshopError = error.detail || 'Failed to claim vendor';
}
} else if (lookupData.vendor?.is_claimed) {
this.letzshopError = 'This shop has already been claimed.';
} else {
this.letzshopError = lookupData.error || 'Shop not found.';
}
} catch (error) {
console.error('Error:', error);
this.letzshopError = 'Failed to lookup vendor.';
} finally {
this.loading = false;
}
} else {
// Skip this step
this.currentStep = 3;
}
},
isAccountValid() {
return this.account.firstName.trim() &&
this.account.lastName.trim() &&
this.account.companyName.trim() &&
this.account.email.trim() &&
this.account.password.length >= 8;
},
async createAccount() {
this.loading = true;
this.accountError = null;
try {
const response = await fetch('/api/v1/public/signup/create-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
email: this.account.email,
password: this.account.password,
first_name: this.account.firstName,
last_name: this.account.lastName,
company_name: this.account.companyName
})
});
const data = await response.json();
if (response.ok) {
this.currentStep = 4;
} else {
this.accountError = data.detail || 'Failed to create account';
}
} catch (error) {
console.error('Error:', error);
this.accountError = 'Failed to create account. Please try again.';
} finally {
this.loading = false;
}
},
async initStripe() {
{% if stripe_publishable_key %}
this.stripe = Stripe('{{ stripe_publishable_key }}');
const elements = this.stripe.elements();
this.cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#374151',
'::placeholder': { color: '#9CA3AF' }
}
}
});
this.cardElement.mount('#card-element');
this.cardElement.on('change', (event) => {
const displayError = document.getElementById('card-errors');
displayError.textContent = event.error ? event.error.message : '';
});
// Get SetupIntent
try {
const response = await fetch('/api/v1/public/signup/setup-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId })
});
const data = await response.json();
if (response.ok) {
this.clientSecret = data.client_secret;
}
} catch (error) {
console.error('Error getting SetupIntent:', error);
}
{% else %}
console.warn('Stripe not configured');
{% endif %}
},
async submitPayment() {
if (!this.stripe || !this.clientSecret) {
alert('Payment not configured. Please contact support.');
return;
}
this.paymentProcessing = true;
try {
const { setupIntent, error } = await this.stripe.confirmCardSetup(
this.clientSecret,
{ payment_method: { card: this.cardElement } }
);
if (error) {
document.getElementById('card-errors').textContent = error.message;
this.paymentProcessing = false;
return;
}
// Complete signup
const response = await fetch('/api/v1/public/signup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
setup_intent_id: setupIntent.id
})
});
const data = await response.json();
if (response.ok) {
// Store access token for automatic login
if (data.access_token) {
localStorage.setItem('vendor_token', data.access_token);
localStorage.setItem('vendorCode', data.vendor_code);
console.log('Vendor token stored for automatic login');
}
window.location.href = '/signup/success?vendor_code=' + data.vendor_code;
} else {
alert(data.detail || 'Failed to complete signup');
}
} catch (error) {
console.error('Payment error:', error);
alert('Payment failed. Please try again.');
} finally {
this.paymentProcessing = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,428 @@
{# app/templates/vendor/billing.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Billing & Subscription{% endblock %}
{% block alpine_data %}vendorBilling(){% endblock %}
{% block content %}
{{ page_header('Billing & Subscription') }}
<!-- Success/Cancel Messages -->
<template x-if="showSuccessMessage">
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
<span>Your subscription has been updated successfully!</span>
</div>
<button @click="showSuccessMessage = false" class="text-green-700 hover:text-green-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<template x-if="showCancelMessage">
<div class="mb-6 p-4 bg-yellow-100 border border-yellow-400 text-yellow-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 mr-2')"></span>
<span>Checkout was cancelled. No changes were made to your subscription.</span>
</div>
<button @click="showCancelMessage = false" class="text-yellow-700 hover:text-yellow-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<template x-if="showAddonSuccessMessage">
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
<div class="flex items-center">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
<span>Add-on purchased successfully!</span>
</div>
<button @click="showAddonSuccessMessage = false" class="text-green-700 hover:text-green-900">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
</template>
<!-- Loading State -->
<template x-if="loading">
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
</div>
</template>
<template x-if="!loading">
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
<!-- Current Plan Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Current Plan</h3>
<span :class="{
'bg-green-100 text-green-800': subscription?.status === 'active',
'bg-yellow-100 text-yellow-800': subscription?.status === 'trial',
'bg-red-100 text-red-800': subscription?.status === 'past_due' || subscription?.status === 'cancelled',
'bg-gray-100 text-gray-800': !['active', 'trial', 'past_due', 'cancelled'].includes(subscription?.status)
}" class="px-2 py-1 text-xs font-semibold rounded-full">
<span x-text="subscription?.status?.replace('_', ' ')?.toUpperCase() || 'INACTIVE'"></span>
</span>
</div>
<div class="mb-4">
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400" x-text="subscription?.tier_name || 'No Plan'"></div>
<template x-if="subscription?.is_trial">
<p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">
Trial ends <span x-text="formatDate(subscription?.trial_ends_at)"></span>
</p>
</template>
<template x-if="subscription?.cancelled_at">
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
Cancels on <span x-text="formatDate(subscription?.period_end)"></span>
</p>
</template>
</div>
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<template x-if="subscription?.period_end && !subscription?.cancelled_at">
<p>
Next billing: <span class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(subscription?.period_end)"></span>
</p>
</template>
</div>
<div class="mt-6 space-y-2">
<template x-if="subscription?.stripe_customer_id">
<button @click="openPortal()"
class="w-full px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
Manage Payment Method
</button>
</template>
<template x-if="subscription?.cancelled_at">
<button @click="reactivate()"
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
Reactivate Subscription
</button>
</template>
<template x-if="!subscription?.cancelled_at && subscription?.status === 'active'">
<button @click="showCancelModal = true"
class="w-full px-4 py-2 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900 dark:text-red-300">
Cancel Subscription
</button>
</template>
</div>
</div>
<!-- Usage Summary Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Usage This Period</h3>
<!-- Orders Usage -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 dark:text-gray-400">Orders</span>
<span class="font-medium text-gray-800 dark:text-gray-200">
<span x-text="subscription?.orders_this_period || 0"></span>
<span x-text="subscription?.orders_limit ? ` / ${subscription.orders_limit}` : ' (Unlimited)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="bg-purple-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${subscription?.orders_limit ? Math.min(100, (subscription.orders_this_period / subscription.orders_limit) * 100) : 0}%`"></div>
</div>
</div>
<!-- Products Usage -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 dark:text-gray-400">Products</span>
<span class="font-medium text-gray-800 dark:text-gray-200">
<span x-text="subscription?.products_count || 0"></span>
<span x-text="subscription?.products_limit ? ` / ${subscription.products_limit}` : ' (Unlimited)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${subscription?.products_limit ? Math.min(100, (subscription.products_count / subscription.products_limit) * 100) : 0}%`"></div>
</div>
</div>
<!-- Team Usage -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 dark:text-gray-400">Team Members</span>
<span class="font-medium text-gray-800 dark:text-gray-200">
<span x-text="subscription?.team_count || 0"></span>
<span x-text="subscription?.team_limit ? ` / ${subscription.team_limit}` : ' (Unlimited)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div class="bg-green-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${subscription?.team_limit ? Math.min(100, (subscription.team_count / subscription.team_limit) * 100) : 0}%`"></div>
</div>
</div>
<template x-if="subscription?.last_payment_error">
<div class="mt-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">
<span x-html="$icon('exclamation-circle', 'w-4 h-4 inline mr-1')"></span>
Payment issue: <span x-text="subscription.last_payment_error"></span>
</p>
</div>
</template>
</div>
<!-- Quick Actions Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Quick Actions</h3>
<div class="space-y-3">
<button @click="showTiersModal = true"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('arrow-trending-up', 'w-5 h-5 text-purple-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Change Plan</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</button>
<button @click="showAddonsModal = true"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('puzzle-piece', 'w-5 h-5 text-blue-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Add-ons</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</button>
<a :href="`/vendor/${vendorCode}/invoices`"
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
<div class="flex items-center">
<span x-html="$icon('document-text', 'w-5 h-5 text-green-600 mr-3')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">View Invoices</span>
</div>
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
</a>
</div>
</div>
</div>
<!-- Invoice History Section -->
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Recent Invoices</h3>
<template x-if="invoices.length === 0">
<p class="text-gray-500 dark:text-gray-400 text-center py-8">No invoices yet</p>
</template>
<template x-if="invoices.length > 0">
<div class="overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Invoice</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Amount</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="invoice in invoices.slice(0, 5)" :key="invoice.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm font-medium" x-text="invoice.invoice_number || `#${invoice.id}`"></td>
<td class="px-4 py-3 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
<td class="px-4 py-3 text-sm font-medium" x-text="formatCurrency(invoice.total_cents, invoice.currency)"></td>
<td class="px-4 py-3 text-sm">
<span :class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'open',
'bg-red-100 text-red-800': invoice.status === 'uncollectible'
}" class="px-2 py-1 text-xs font-semibold rounded-full" x-text="invoice.status.toUpperCase()"></span>
</td>
<td class="px-4 py-3 text-sm">
<template x-if="invoice.pdf_url">
<a :href="invoice.pdf_url" target="_blank" class="text-purple-600 hover:text-purple-800">
<span x-html="$icon('arrow-down-tray', 'w-5 h-5')"></span>
</a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</template>
<!-- Tiers Modal -->
{% call modal_simple('tiersModal', 'Choose Your Plan', show_var='showTiersModal', size='xl') %}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<template x-for="tier in tiers" :key="tier.code">
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
<template x-if="tier.is_current">
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
</template>
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
<span class="text-sm font-normal text-gray-500">/mo</span>
</p>
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
</li>
<li class="flex items-center">
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
</li>
</ul>
<button @click="selectTier(tier)"
:disabled="tier.is_current"
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
</button>
</div>
</template>
</div>
{% endcall %}
<!-- Add-ons Modal -->
<div x-show="showAddonsModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showAddonsModal = false">
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col">
<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">Add-ons</h3>
<button @click="showAddonsModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6 overflow-y-auto">
<!-- My Active Add-ons -->
<template x-if="myAddons.length > 0">
<div class="mb-6">
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Your Active Add-ons</h4>
<div class="space-y-3">
<template x-for="addon in myAddons.filter(a => a.status === 'active')" :key="addon.id">
<div class="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.addon_name"></h4>
<template x-if="addon.domain_name">
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.domain_name"></p>
</template>
<p class="text-xs text-gray-400 mt-1">
<span x-text="addon.period_end ? `Renews ${formatDate(addon.period_end)}` : 'Active'"></span>
</p>
</div>
<button @click="cancelAddon(addon)"
class="px-3 py-1 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900/50 dark:text-red-400">
Cancel
</button>
</div>
</template>
</div>
</div>
</template>
<!-- Available Add-ons -->
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Available Add-ons</h4>
<template x-if="addons.length === 0">
<p class="text-gray-500 text-center py-8">No add-ons available</p>
</template>
<div class="space-y-3">
<template x-for="addon in addons" :key="addon.id">
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.name"></h4>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.description"></p>
<p class="text-sm font-medium text-purple-600 mt-1">
<span x-text="formatCurrency(addon.price_cents, 'EUR')"></span>
<span x-text="`/${addon.billing_period}`"></span>
</p>
</div>
<button @click="purchaseAddon(addon)"
:disabled="isAddonPurchased(addon.code) || purchasingAddon === addon.code"
:class="isAddonPurchased(addon.code) ? 'bg-gray-200 text-gray-500 cursor-not-allowed' : 'bg-purple-100 text-purple-600 hover:bg-purple-200'"
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
<template x-if="purchasingAddon === addon.code">
<span class="flex items-center">
<span class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
Processing...
</span>
</template>
<template x-if="purchasingAddon !== addon.code">
<span x-text="isAddonPurchased(addon.code) ? 'Active' : 'Add'"></span>
</template>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Cancel Subscription Modal -->
<div x-show="showCancelModal"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="showCancelModal = false">
<div class="w-full max-w-md mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<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">Cancel Subscription</h3>
<button @click="showCancelModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
</button>
</div>
<div class="p-6">
<p class="text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period.
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Reason for cancelling (optional)
</label>
<textarea x-model="cancelReason"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Tell us why you're leaving..."></textarea>
</div>
<div class="flex justify-end space-x-3">
<button @click="showCancelModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
Keep Subscription
</button>
<button @click="cancelSubscription()"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
Cancel Subscription
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="/static/modules/billing/vendor/js/billing.js"></script>
{% endblock %}

View File

@@ -2,7 +2,10 @@
"""
Cart module exceptions.
Module-specific exceptions for shopping cart operations.
This module provides exception classes for cart operations including:
- Cart item management
- Quantity validation
- Inventory checks
"""
from app.exceptions.base import (
@@ -11,6 +14,15 @@ from app.exceptions.base import (
ValidationException,
)
__all__ = [
"CartItemNotFoundException",
"EmptyCartException",
"CartValidationException",
"InsufficientInventoryForCartException",
"InvalidCartQuantityException",
"ProductNotAvailableForCartException",
]
class CartItemNotFoundException(ResourceNotFoundException):
"""Raised when a cart item is not found."""
@@ -115,11 +127,3 @@ class ProductNotAvailableForCartException(BusinessLogicException):
)
__all__ = [
"CartItemNotFoundException",
"CartValidationException",
"EmptyCartException",
"InsufficientInventoryForCartException",
"InvalidCartQuantityException",
"ProductNotAvailableForCartException",
]

View File

@@ -0,0 +1,2 @@
# app/modules/cart/routes/pages/__init__.py
"""Cart module page routes."""

View File

@@ -0,0 +1,46 @@
# app/modules/cart/routes/pages/storefront.py
"""
Cart Storefront Page Routes (HTML rendering).
Storefront (customer shop) pages for shopping cart:
- Cart page
"""
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.modules.core.utils.page_context import get_storefront_context
from app.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# SHOPPING CART
# ============================================================================
@router.get("/cart", response_class=HTMLResponse, include_in_schema=False)
async def shop_cart_page(request: Request, db: Session = Depends(get_db)):
"""
Render shopping cart page.
Shows cart items and allows quantity updates.
"""
logger.debug(
"[STOREFRONT] shop_cart_page REACHED",
extra={
"path": request.url.path,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"cart/storefront/cart.html", get_storefront_context(request, db=db)
)

View File

@@ -16,12 +16,12 @@ import logging
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.exceptions import (
from app.modules.cart.exceptions import (
CartItemNotFoundException,
InsufficientInventoryForCartException,
InvalidCartQuantityException,
ProductNotFoundException,
)
from app.modules.catalog.exceptions import ProductNotFoundException
from app.utils.money import cents_to_euros
from app.modules.cart.models.cart import CartItem
from app.modules.catalog.models import Product

View File

@@ -0,0 +1,319 @@
{# app/templates/storefront/cart.html #}
{% extends "storefront/base.html" %}
{% block title %}Shopping Cart{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shoppingCart(){% endblock %}
{% block content %}
<div class="max-w-7xl 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>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Cart</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-2">
Shopping Cart
</h1>
</div>
{# Loading State #}
<div x-show="loading && items.length === 0" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Empty Cart #}
<div x-show="!loading && items.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">🛒</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Your cart is empty
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Add some products to get started!
</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
Browse Products
</a>
</div>
{# Cart Items #}
<div x-show="items.length > 0" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# Cart Items List #}
<div class="lg:col-span-2 space-y-4">
<template x-for="item in items" :key="item.product_id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex gap-6">
{# Item Image #}
<div class="flex-shrink-0">
<img
:src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="item.name"
class="w-24 h-24 object-cover rounded-lg"
>
</div>
{# Item Details #}
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-1" x-text="item.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2" x-text="'SKU: ' + item.sku"></p>
<p class="text-xl font-bold text-primary mb-4">
<span x-text="parseFloat(item.price).toFixed(2)"></span>
</p>
{# Quantity Controls #}
{# noqa: FE-008 - Custom quantity stepper with async updateQuantity() per-item and :value binding #}
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<button
@click="updateQuantity(item.product_id, item.quantity - 1)"
:disabled="item.quantity <= 1 || updating"
class="w-8 h-8 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<input
type="number"
:value="item.quantity"
@change="updateQuantity(item.product_id, $event.target.value)"
min="1"
max="99"
:disabled="updating"
class="w-16 text-center px-2 py-1 border-2 border-gray-300 dark:border-gray-600 rounded font-semibold dark:bg-gray-700 dark:text-white"
>
<button
@click="updateQuantity(item.product_id, item.quantity + 1)"
:disabled="updating"
class="w-8 h-8 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
+
</button>
</div>
<div class="flex-1 text-right">
<p class="text-sm text-gray-500 dark:text-gray-400">Subtotal</p>
<p class="text-xl font-bold text-gray-800 dark:text-gray-200">
<span x-text="(parseFloat(item.price) * item.quantity).toFixed(2)"></span>
</p>
</div>
<button
@click="removeItem(item.product_id)"
:disabled="updating"
class="w-10 h-10 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded hover:bg-red-600 hover:border-red-600 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Remove from cart"
>
🗑️
</button>
</div>
</div>
</div>
</div>
</template>
</div>
{# Cart Summary #}
<div class="lg:col-span-1">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 sticky top-4">
<h3 class="text-xl font-semibold mb-6 pb-4 border-b-2 border-gray-200 dark:border-gray-700">
Order Summary
</h3>
<div class="space-y-3 mb-6">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Subtotal (<span x-text="totalItems"></span> items):</span>
<span class="font-semibold"><span x-text="subtotal.toFixed(2)"></span></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Shipping:</span>
<span class="font-semibold" x-text="shipping > 0 ? '€' + shipping.toFixed(2) : 'FREE'"></span>
</div>
<div class="flex justify-between text-lg font-bold pt-3 border-t-2 border-gray-200 dark:border-gray-700">
<span>Total:</span>
<span class="text-primary text-2xl"><span x-text="total.toFixed(2)"></span></span>
</div>
</div>
<button
@click="proceedToCheckout()"
:disabled="updating || items.length === 0"
class="w-full px-6 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-primary-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed mb-3"
>
Proceed to Checkout
</button>
<a href="{{ base_url }}shop/products" class="block w-full px-6 py-3 text-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
Continue Shopping
</a>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
Free shipping on orders over €50
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shoppingCart', () => {
const baseData = shopLayoutData();
return {
...baseData,
items: [],
loading: false,
updating: false,
// Computed properties
get totalItems() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
},
get subtotal() {
return this.items.reduce((sum, item) =>
sum + (parseFloat(item.price) * item.quantity), 0
);
},
get shipping() {
// Free shipping over €50
return this.subtotal >= 50 ? 0 : 5.99;
},
get total() {
return this.subtotal + this.shipping;
},
// Initialize
async init() {
console.log('[SHOP] Cart page initializing...');
// Call parent init to set up sessionId
if (baseData.init) {
baseData.init.call(this);
}
await this.loadCart();
},
// Load cart from API
async loadCart() {
this.loading = true;
try {
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
this.items = data.items || [];
this.cartCount = this.totalItems;
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
}
} catch (error) {
console.error('[SHOP] Failed to load cart:', error);
this.showToast('Failed to load cart', 'error');
} finally {
this.loading = false;
}
},
// Update item quantity
async updateQuantity(productId, newQuantity) {
newQuantity = parseInt(newQuantity);
if (newQuantity < 1 || newQuantity > 99) return;
this.updating = true;
try {
console.log('[SHOP] Updating quantity:', productId, newQuantity);
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ quantity: newQuantity })
}
);
if (response.ok) {
await this.loadCart();
this.showToast('Quantity updated', 'success');
} else {
throw new Error('Failed to update quantity');
}
} catch (error) {
console.error('[SHOP] Update quantity error:', error);
this.showToast('Failed to update quantity', 'error');
} finally {
this.updating = false;
}
},
// Remove item from cart
async removeItem(productId) {
if (!confirm('Remove this item from your cart?')) {
return;
}
this.updating = true;
try {
console.log('[SHOP] Removing item:', productId);
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
{
method: 'DELETE'
}
);
if (response.ok) {
await this.loadCart();
this.showToast('Item removed from cart', 'success');
} else {
throw new Error('Failed to remove item');
}
} catch (error) {
console.error('[SHOP] Remove item error:', error);
this.showToast('Failed to remove item', 'error');
} finally {
this.updating = false;
}
},
// Proceed to checkout
proceedToCheckout() {
// Check if customer is logged in
const token = localStorage.getItem('customer_token');
if (!token) {
// Redirect to login with return URL
window.location.href = '{{ base_url }}shop/account/login?return={{ base_url }}shop/checkout';
} else {
window.location.href = '{{ base_url }}shop/checkout';
}
}
};
});
});
</script>
{% endblock %}

View File

@@ -2,7 +2,10 @@
"""
Catalog module exceptions.
Module-specific exceptions for product catalog operations.
This module provides exception classes for catalog operations including:
- Product management (CRUD)
- Product validation
- Product dependencies (inventory, orders)
"""
from app.exceptions.base import (
@@ -12,6 +15,19 @@ from app.exceptions.base import (
ValidationException,
)
__all__ = [
"ProductNotFoundException",
"ProductAlreadyExistsException",
"ProductNotInCatalogException",
"ProductNotActiveException",
"InvalidProductDataException",
"ProductValidationException",
"CannotDeleteProductException",
"CannotDeleteProductWithInventoryException",
"CannotDeleteProductWithOrdersException",
"ProductMediaException",
]
class ProductNotFoundException(ResourceNotFoundException):
"""Raised when a product is not found in vendor catalog."""
@@ -56,11 +72,9 @@ class ProductNotInCatalogException(ResourceNotFoundException):
identifier=str(product_id),
message=f"Product {product_id} is not in vendor {vendor_id} catalog",
error_code="PRODUCT_NOT_IN_CATALOG",
details={
"product_id": product_id,
"vendor_id": vendor_id,
},
)
self.details["product_id"] = product_id
self.details["vendor_id"] = vendor_id
class ProductNotActiveException(BusinessLogicException):
@@ -77,6 +91,23 @@ class ProductNotActiveException(BusinessLogicException):
)
class InvalidProductDataException(ValidationException):
"""Raised when product data is invalid."""
def __init__(
self,
message: str = "Invalid product data",
field: str | None = None,
details: dict | None = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_PRODUCT_DATA"
class ProductValidationException(ValidationException):
"""Raised when product data validation fails."""
@@ -109,6 +140,34 @@ class CannotDeleteProductException(BusinessLogicException):
)
class CannotDeleteProductWithInventoryException(BusinessLogicException):
"""Raised when trying to delete a product that has inventory."""
def __init__(self, product_id: int, inventory_count: int):
super().__init__(
message=f"Cannot delete product {product_id} - it has {inventory_count} inventory entries",
error_code="CANNOT_DELETE_PRODUCT_WITH_INVENTORY",
details={
"product_id": product_id,
"inventory_count": inventory_count,
},
)
class CannotDeleteProductWithOrdersException(BusinessLogicException):
"""Raised when trying to delete a product that has been ordered."""
def __init__(self, product_id: int, order_count: int):
super().__init__(
message=f"Cannot delete product {product_id} - it has {order_count} associated orders",
error_code="CANNOT_DELETE_PRODUCT_WITH_ORDERS",
details={
"product_id": product_id,
"order_count": order_count,
},
)
class ProductMediaException(BusinessLogicException):
"""Raised when there's an issue with product media."""
@@ -118,14 +177,3 @@ class ProductMediaException(BusinessLogicException):
error_code="PRODUCT_MEDIA_ERROR",
details={"product_id": product_id},
)
__all__ = [
"CannotDeleteProductException",
"ProductAlreadyExistsException",
"ProductMediaException",
"ProductNotActiveException",
"ProductNotFoundException",
"ProductNotInCatalogException",
"ProductValidationException",
]

View File

@@ -17,8 +17,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db
from app.services.subscription_service import subscription_service
from app.services.vendor_product_service import vendor_product_service
from app.modules.billing.services.subscription_service import subscription_service
from app.modules.catalog.services.vendor_product_service import vendor_product_service
from models.schema.auth import UserContext
from app.modules.catalog.schemas import (
CatalogVendor,

View File

@@ -109,7 +109,7 @@ def get_product_details(
# Check if product is active
if not product.is_active:
from app.exceptions import ProductNotActiveException
from app.modules.catalog.exceptions import ProductNotActiveException
raise ProductNotActiveException(str(product_id))

View File

@@ -15,9 +15,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.services.product_service import product_service
from app.services.subscription_service import subscription_service
from app.services.vendor_product_service import vendor_product_service
from app.modules.catalog.services.product_service import product_service
from app.modules.billing.services.subscription_service import subscription_service
from app.modules.catalog.services.vendor_product_service import vendor_product_service
from models.schema.auth import UserContext
from app.modules.catalog.schemas import (
ProductCreate,

View File

@@ -0,0 +1,2 @@
# app/modules/catalog/routes/pages/__init__.py
"""Catalog module page routes."""

View File

@@ -0,0 +1,110 @@
# app/modules/catalog/routes/pages/admin.py
"""
Catalog Admin Page Routes (HTML rendering).
Admin pages for vendor product catalog management:
- Vendor products list
- Vendor product create
- Vendor product detail/edit
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# VENDOR PRODUCT CATALOG ROUTES
# ============================================================================
@router.get("/vendor-products", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendor_products_page(
request: Request,
current_user: User = Depends(
require_menu_access("vendor-products", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render vendor products catalog page.
Browse vendor-specific product catalogs with override capability.
"""
return templates.TemplateResponse(
"catalog/admin/vendor-products.html",
get_admin_context(request, current_user),
)
@router.get(
"/vendor-products/create", response_class=HTMLResponse, include_in_schema=False
)
async def admin_vendor_product_create_page(
request: Request,
current_user: User = Depends(
require_menu_access("vendor-products", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render vendor product create page.
Create a new vendor product entry.
"""
return templates.TemplateResponse(
"catalog/admin/vendor-product-create.html",
get_admin_context(request, current_user),
)
@router.get(
"/vendor-products/{product_id}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_vendor_product_detail_page(
request: Request,
product_id: int = Path(..., description="Vendor Product ID"),
current_user: User = Depends(
require_menu_access("vendor-products", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render vendor product detail page.
Shows full product information with vendor-specific overrides.
"""
return templates.TemplateResponse(
"catalog/admin/vendor-product-detail.html",
get_admin_context(request, current_user, product_id=product_id),
)
@router.get(
"/vendor-products/{product_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
)
async def admin_vendor_product_edit_page(
request: Request,
product_id: int = Path(..., description="Vendor Product ID"),
current_user: User = Depends(
require_menu_access("vendor-products", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render vendor product edit page.
Edit vendor product information and overrides.
"""
return templates.TemplateResponse(
"catalog/admin/vendor-product-edit.html",
get_admin_context(request, current_user, product_id=product_id),
)

View File

@@ -0,0 +1,171 @@
# app/modules/catalog/routes/pages/storefront.py
"""
Catalog Storefront Page Routes (HTML rendering).
Storefront (customer shop) pages for catalog browsing:
- Shop homepage / product catalog
- Product list
- Product detail
- Category products
- Search results
"""
import logging
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.modules.core.utils.page_context import get_storefront_context
from app.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# PUBLIC SHOP ROUTES (No Authentication Required)
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
"""
Render shop homepage / product catalog.
Shows featured products and categories.
"""
logger.debug(
"[STOREFRONT] shop_products_page REACHED",
extra={
"path": request.url.path,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"catalog/storefront/products.html", get_storefront_context(request, db=db)
)
@router.get(
"/products/{product_id}", response_class=HTMLResponse, include_in_schema=False
)
async def shop_product_detail_page(
request: Request,
product_id: int = Path(..., description="Product ID"),
db: Session = Depends(get_db),
):
"""
Render product detail page.
Shows product information, images, reviews, and buy options.
"""
logger.debug(
"[STOREFRONT] shop_product_detail_page REACHED",
extra={
"path": request.url.path,
"product_id": product_id,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"catalog/storefront/product.html",
get_storefront_context(request, db=db, product_id=product_id),
)
@router.get(
"/categories/{category_slug}",
response_class=HTMLResponse,
include_in_schema=False,
)
async def shop_category_page(
request: Request,
category_slug: str = Path(..., description="Category slug"),
db: Session = Depends(get_db),
):
"""
Render category products page.
Shows all products in a specific category.
"""
logger.debug(
"[STOREFRONT] shop_category_page REACHED",
extra={
"path": request.url.path,
"category_slug": category_slug,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"catalog/storefront/category.html",
get_storefront_context(request, db=db, category_slug=category_slug),
)
@router.get("/search", response_class=HTMLResponse, include_in_schema=False)
async def shop_search_page(request: Request, db: Session = Depends(get_db)):
"""
Render search results page.
Shows products matching search query.
"""
logger.debug(
"[STOREFRONT] shop_search_page REACHED",
extra={
"path": request.url.path,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"catalog/storefront/search.html", get_storefront_context(request, db=db)
)
# ============================================================================
# CUSTOMER ACCOUNT - WISHLIST
# ============================================================================
@router.get(
"/account/wishlist", response_class=HTMLResponse, include_in_schema=False
)
async def shop_wishlist_page(
request: Request,
db: Session = Depends(get_db),
):
"""
Render customer wishlist page.
View and manage saved products.
Requires customer authentication (handled by middleware/template).
"""
from app.api.deps import get_current_customer_from_cookie_or_header
# Get customer if authenticated
try:
current_customer = await get_current_customer_from_cookie_or_header(
request=request, db=db
)
except Exception:
current_customer = None
logger.debug(
"[STOREFRONT] shop_wishlist_page REACHED",
extra={
"path": request.url.path,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"catalog/storefront/wishlist.html",
get_storefront_context(request, db=db, user=current_customer),
)

View File

@@ -0,0 +1,64 @@
# app/modules/catalog/routes/pages/vendor.py
"""
Catalog Vendor Page Routes (HTML rendering).
Vendor pages for product management:
- Products list
- Product create
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates
from models.database.user import User
router = APIRouter()
# ============================================================================
# PRODUCT MANAGEMENT
# ============================================================================
@router.get(
"/{vendor_code}/products", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_products_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render products management page.
JavaScript loads product list via API.
"""
return templates.TemplateResponse(
"catalog/vendor/products.html",
get_vendor_context(request, db, current_user, vendor_code),
)
@router.get(
"/{vendor_code}/products/create",
response_class=HTMLResponse,
include_in_schema=False,
)
async def vendor_product_create_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render product creation page.
JavaScript handles form submission via API.
"""
return templates.TemplateResponse(
"catalog/vendor/product-create.html",
get_vendor_context(request, db, current_user, vendor_code),
)

View File

@@ -2,5 +2,16 @@
"""Catalog module services."""
from app.modules.catalog.services.catalog_service import catalog_service
from app.modules.catalog.services.product_service import ProductService, product_service
from app.modules.catalog.services.vendor_product_service import (
VendorProductService,
vendor_product_service,
)
__all__ = ["catalog_service"]
__all__ = [
"catalog_service",
"ProductService",
"product_service",
"VendorProductService",
"vendor_product_service",
]

View File

@@ -17,7 +17,8 @@ import logging
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
from app.exceptions import ProductNotFoundException, ValidationException
from app.exceptions import ValidationException
from app.modules.catalog.exceptions import ProductNotFoundException
from app.modules.catalog.models import Product, ProductTranslation
logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,335 @@
# app/modules/catalog/services/product_service.py
"""
Product service for vendor catalog management.
This module provides:
- Product catalog CRUD operations
- Product publishing from marketplace staging
- Product search and filtering
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.exceptions import ValidationException
from app.modules.catalog.exceptions import (
ProductAlreadyExistsException,
ProductNotFoundException,
)
from app.modules.marketplace.models import MarketplaceProduct
from app.modules.catalog.models import Product
from app.modules.catalog.schemas import ProductCreate, ProductUpdate
logger = logging.getLogger(__name__)
class ProductService:
"""Service for vendor catalog product operations."""
def get_product(self, db: Session, vendor_id: int, product_id: int) -> Product:
"""
Get a product from vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
Product object
Raises:
ProductNotFoundException: If product not found
"""
try:
product = (
db.query(Product)
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
.first()
)
if not product:
raise ProductNotFoundException(f"Product {product_id} not found")
return product
except ProductNotFoundException:
raise
except Exception as e:
logger.error(f"Error getting product: {str(e)}")
raise ValidationException("Failed to retrieve product")
def create_product(
self, db: Session, vendor_id: int, product_data: ProductCreate
) -> Product:
"""
Add a product from marketplace to vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_data: Product creation data
Returns:
Created Product object
Raises:
ProductAlreadyExistsException: If product already in catalog
ValidationException: If marketplace product not found
"""
try:
# Verify marketplace product exists
marketplace_product = (
db.query(MarketplaceProduct)
.filter(MarketplaceProduct.id == product_data.marketplace_product_id)
.first()
)
if not marketplace_product:
raise ValidationException(
f"Marketplace product {product_data.marketplace_product_id} not found"
)
# Check if already in catalog
existing = (
db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.marketplace_product_id
== product_data.marketplace_product_id,
)
.first()
)
if existing:
raise ProductAlreadyExistsException("Product already exists in catalog")
# Create product
product = Product(
vendor_id=vendor_id,
marketplace_product_id=product_data.marketplace_product_id,
vendor_sku=product_data.vendor_sku,
price=product_data.price,
sale_price=product_data.sale_price,
currency=product_data.currency,
availability=product_data.availability,
condition=product_data.condition,
is_featured=product_data.is_featured,
is_active=True,
min_quantity=product_data.min_quantity,
max_quantity=product_data.max_quantity,
)
db.add(product)
db.flush()
db.refresh(product)
logger.info(f"Added product {product.id} to vendor {vendor_id} catalog")
return product
except (ProductAlreadyExistsException, ValidationException):
raise
except Exception as e:
logger.error(f"Error creating product: {str(e)}")
raise ValidationException("Failed to create product")
def update_product(
self,
db: Session,
vendor_id: int,
product_id: int,
product_update: ProductUpdate,
) -> Product:
"""
Update product in vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
product_update: Update data
Returns:
Updated Product object
"""
try:
product = self.get_product(db, vendor_id, product_id)
# Update fields
update_data = product_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(product, key, value)
product.updated_at = datetime.now(UTC)
db.flush()
db.refresh(product)
logger.info(f"Updated product {product_id} in vendor {vendor_id} catalog")
return product
except ProductNotFoundException:
raise
except Exception as e:
logger.error(f"Error updating product: {str(e)}")
raise ValidationException("Failed to update product")
def delete_product(self, db: Session, vendor_id: int, product_id: int) -> bool:
"""
Remove product from vendor catalog.
Args:
db: Database session
vendor_id: Vendor ID
product_id: Product ID
Returns:
True if deleted
"""
try:
product = self.get_product(db, vendor_id, product_id)
db.delete(product)
logger.info(f"Deleted product {product_id} from vendor {vendor_id} catalog")
return True
except ProductNotFoundException:
raise
except Exception as e:
logger.error(f"Error deleting product: {str(e)}")
raise ValidationException("Failed to delete product")
def get_vendor_products(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 100,
is_active: bool | None = None,
is_featured: bool | None = None,
) -> tuple[list[Product], int]:
"""
Get products in vendor catalog with filtering.
Args:
db: Database session
vendor_id: Vendor ID
skip: Pagination offset
limit: Pagination limit
is_active: Filter by active status
is_featured: Filter by featured status
Returns:
Tuple of (products, total_count)
"""
try:
query = db.query(Product).filter(Product.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(Product.is_active == is_active)
if is_featured is not None:
query = query.filter(Product.is_featured == is_featured)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except Exception as e:
logger.error(f"Error getting vendor products: {str(e)}")
raise ValidationException("Failed to retrieve products")
def search_products(
self,
db: Session,
vendor_id: int,
query: str,
skip: int = 0,
limit: int = 50,
language: str = "en",
) -> tuple[list[Product], int]:
"""
Search products in vendor catalog.
Searches across:
- Product title and description (from translations)
- Product SKU, brand, and GTIN
Args:
db: Database session
vendor_id: Vendor ID
query: Search query string
skip: Pagination offset
limit: Pagination limit
language: Language for translation search (default: 'en')
Returns:
Tuple of (products, total_count)
"""
from sqlalchemy import or_
from sqlalchemy.orm import joinedload
from app.modules.catalog.models import ProductTranslation
try:
# Prepare search pattern for LIKE queries
search_pattern = f"%{query}%"
# Use subquery to get distinct IDs (PostgreSQL can't compare JSON for DISTINCT)
id_subquery = (
db.query(Product.id)
.outerjoin(
ProductTranslation,
(Product.id == ProductTranslation.product_id)
& (ProductTranslation.language == language),
)
.filter(
Product.vendor_id == vendor_id,
Product.is_active == True,
)
.filter(
or_(
# Search in translations
ProductTranslation.title.ilike(search_pattern),
ProductTranslation.description.ilike(search_pattern),
ProductTranslation.short_description.ilike(search_pattern),
# Search in product fields
Product.vendor_sku.ilike(search_pattern),
Product.brand.ilike(search_pattern),
Product.gtin.ilike(search_pattern),
)
)
.distinct()
.subquery()
)
base_query = db.query(Product).filter(
Product.id.in_(db.query(id_subquery.c.id))
)
# Get total count
total = base_query.count()
# Get paginated results with eager loading for performance
products = (
base_query.options(joinedload(Product.translations))
.offset(skip)
.limit(limit)
.all()
)
logger.debug(
f"Search '{query}' for vendor {vendor_id}: {total} results"
)
return products, total
except Exception as e:
logger.error(f"Error searching products: {str(e)}")
raise ValidationException("Failed to search products")
# Create service instance
product_service = ProductService()

View File

@@ -0,0 +1,484 @@
# app/modules/catalog/services/vendor_product_service.py
"""
Vendor product service for managing vendor-specific product catalogs.
This module provides:
- Vendor product catalog browsing
- Product search and filtering
- Product statistics
- Product removal from catalogs
"""
import logging
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from app.modules.catalog.exceptions import ProductNotFoundException
from app.modules.catalog.models import Product
from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class VendorProductService:
"""Service for vendor product catalog operations."""
def get_products(
self,
db: Session,
skip: int = 0,
limit: int = 50,
search: str | None = None,
vendor_id: int | None = None,
is_active: bool | None = None,
is_featured: bool | None = None,
language: str = "en",
) -> tuple[list[dict], int]:
"""
Get vendor products with search and filtering.
Returns:
Tuple of (products list as dicts, total count)
"""
query = (
db.query(Product)
.join(Vendor, Product.vendor_id == Vendor.id)
.options(
joinedload(Product.vendor),
joinedload(Product.marketplace_product),
joinedload(Product.translations),
)
)
if search:
search_term = f"%{search}%"
query = query.filter(Product.vendor_sku.ilike(search_term))
if vendor_id:
query = query.filter(Product.vendor_id == vendor_id)
if is_active is not None:
query = query.filter(Product.is_active == is_active)
if is_featured is not None:
query = query.filter(Product.is_featured == is_featured)
total = query.count()
products = (
query.order_by(Product.updated_at.desc()).offset(skip).limit(limit).all()
)
result = []
for product in products:
result.append(self._build_product_list_item(product, language))
return result, total
def get_product_stats(self, db: Session, vendor_id: int | None = None) -> dict:
"""Get vendor product statistics for admin dashboard.
Args:
db: Database session
vendor_id: Optional vendor ID to filter stats
Returns:
Dict with product counts (total, active, inactive, etc.)
"""
# Base query filter
base_filter = Product.vendor_id == vendor_id if vendor_id else True
total = db.query(func.count(Product.id)).filter(base_filter).scalar() or 0
active = (
db.query(func.count(Product.id))
.filter(base_filter)
.filter(Product.is_active == True) # noqa: E712
.scalar()
or 0
)
inactive = total - active
featured = (
db.query(func.count(Product.id))
.filter(base_filter)
.filter(Product.is_featured == True) # noqa: E712
.scalar()
or 0
)
# Digital/physical counts
digital = (
db.query(func.count(Product.id))
.filter(base_filter)
.join(Product.marketplace_product)
.filter(Product.marketplace_product.has(is_digital=True))
.scalar()
or 0
)
physical = total - digital
# Count by vendor (only when not filtered by vendor_id)
by_vendor = {}
if not vendor_id:
vendor_counts = (
db.query(
Vendor.name,
func.count(Product.id),
)
.join(Vendor, Product.vendor_id == Vendor.id)
.group_by(Vendor.name)
.all()
)
by_vendor = {name or "unknown": count for name, count in vendor_counts}
return {
"total": total,
"active": active,
"inactive": inactive,
"featured": featured,
"digital": digital,
"physical": physical,
"by_vendor": by_vendor,
}
def get_catalog_vendors(self, db: Session) -> list[dict]:
"""Get list of vendors with products in their catalogs."""
vendors = (
db.query(Vendor.id, Vendor.name, Vendor.vendor_code)
.join(Product, Vendor.id == Product.vendor_id)
.distinct()
.all()
)
return [
{"id": v.id, "name": v.name, "vendor_code": v.vendor_code} for v in vendors
]
def get_product_detail(self, db: Session, product_id: int) -> dict:
"""Get detailed vendor product information including override info."""
product = (
db.query(Product)
.options(
joinedload(Product.vendor),
joinedload(Product.marketplace_product),
joinedload(Product.translations),
)
.filter(Product.id == product_id)
.first()
)
if not product:
raise ProductNotFoundException(product_id)
mp = product.marketplace_product
source_comparison_info = product.get_source_comparison_info()
# Get marketplace product translations (for "view original source")
mp_translations = {}
if mp:
for t in mp.translations:
mp_translations[t.language] = {
"title": t.title,
"description": t.description,
"short_description": t.short_description,
}
# Get vendor translations
vendor_translations = {}
for t in product.translations:
vendor_translations[t.language] = {
"title": t.title,
"description": t.description,
}
# Convenience fields for UI (prefer vendor translations, fallback to English)
title = None
description = None
if vendor_translations:
# Try English first, then first available language
if "en" in vendor_translations:
title = vendor_translations["en"].get("title")
description = vendor_translations["en"].get("description")
elif vendor_translations:
first_lang = next(iter(vendor_translations))
title = vendor_translations[first_lang].get("title")
description = vendor_translations[first_lang].get("description")
return {
"id": product.id,
"vendor_id": product.vendor_id,
"vendor_name": product.vendor.name if product.vendor else None,
"vendor_code": product.vendor.vendor_code if product.vendor else None,
"marketplace_product_id": product.marketplace_product_id,
"vendor_sku": product.vendor_sku,
# Product identifiers
"gtin": product.gtin,
"gtin_type": product.gtin_type or "ean13",
# Product fields with source comparison info
**source_comparison_info,
# Vendor-specific fields
"is_featured": product.is_featured,
"is_active": product.is_active,
"display_order": product.display_order,
"min_quantity": product.min_quantity,
"max_quantity": product.max_quantity,
# Supplier tracking
"supplier": product.supplier,
"supplier_product_id": product.supplier_product_id,
"cost": product.cost,
"margin_percent": product.margin_percent,
# Tax/profit info
"tax_rate_percent": product.tax_rate_percent,
"net_price": product.net_price,
"vat_amount": product.vat_amount,
"profit": product.profit,
"profit_margin_percent": product.profit_margin_percent,
# Digital fulfillment
"download_url": product.download_url,
"license_type": product.license_type,
"fulfillment_email_template": product.fulfillment_email_template,
# Source info from marketplace product
"source_marketplace": mp.marketplace if mp else None,
"source_vendor": mp.vendor_name if mp else None,
"source_gtin": mp.gtin if mp else None,
"source_sku": mp.sku if mp else None,
# Translations
"marketplace_translations": mp_translations,
"vendor_translations": vendor_translations,
# Convenience fields for UI display
"title": title,
"description": description,
"image_url": product.primary_image_url,
"additional_images": product.additional_images or [],
# Timestamps
"created_at": product.created_at.isoformat()
if product.created_at
else None,
"updated_at": product.updated_at.isoformat()
if product.updated_at
else None,
}
def create_product(self, db: Session, data: dict) -> Product:
"""Create a new vendor product.
Args:
db: Database session
data: Product data dict (includes translations dict for multiple languages)
Returns:
Created Product instance
"""
from app.modules.catalog.models import ProductTranslation
# Determine product_type from is_digital flag
is_digital = data.get("is_digital", False)
product_type = "digital" if is_digital else data.get("product_type", "physical")
product = Product(
vendor_id=data["vendor_id"],
vendor_sku=data.get("vendor_sku"),
brand=data.get("brand"),
gtin=data.get("gtin"),
gtin_type=data.get("gtin_type"),
currency=data.get("currency", "EUR"),
tax_rate_percent=data.get("tax_rate_percent", 17),
availability=data.get("availability"),
primary_image_url=data.get("primary_image_url"),
additional_images=data.get("additional_images"),
is_active=data.get("is_active", True),
is_featured=data.get("is_featured", False),
is_digital=is_digital,
product_type=product_type,
)
# Handle price fields via setters (convert to cents)
if data.get("price") is not None:
product.price = data["price"]
if data.get("sale_price") is not None:
product.sale_price = data["sale_price"]
db.add(product)
db.flush() # Get the product ID
# Handle translations dict (new format with multiple languages)
translations = data.get("translations")
if translations:
for lang, trans_data in translations.items():
if trans_data and (trans_data.get("title") or trans_data.get("description")):
translation = ProductTranslation(
product_id=product.id,
language=lang,
title=trans_data.get("title"),
description=trans_data.get("description"),
)
db.add(translation)
else:
# Fallback for old format with single title/description
title = data.get("title")
description = data.get("description")
if title or description:
translation = ProductTranslation(
product_id=product.id,
language="en",
title=title,
description=description,
)
db.add(translation)
db.flush()
logger.info(f"Created vendor product {product.id} for vendor {data['vendor_id']}")
return product
def update_product(self, db: Session, product_id: int, data: dict) -> Product:
"""Update a vendor product.
Args:
db: Database session
product_id: Product ID to update
data: Fields to update (may include translations dict)
Returns:
Updated Product instance
"""
from app.modules.catalog.models import ProductTranslation
product = (
db.query(Product)
.options(joinedload(Product.translations))
.filter(Product.id == product_id)
.first()
)
if not product:
raise ProductNotFoundException(product_id)
# Handle translations separately
if "translations" in data and data["translations"]:
existing_translations = {t.language: t for t in product.translations}
for lang, trans_data in data["translations"].items():
if lang in existing_translations:
# Update existing translation
if "title" in trans_data:
existing_translations[lang].title = trans_data["title"]
if "description" in trans_data:
existing_translations[lang].description = trans_data["description"]
else:
# Create new translation
new_trans = ProductTranslation(
product_id=product_id,
language=lang,
title=trans_data.get("title"),
description=trans_data.get("description"),
)
db.add(new_trans)
# Handle price (convert to cents)
if "price" in data and data["price"] is not None:
product.price = data["price"] # Uses property setter to convert to cents
if "sale_price" in data:
product.sale_price = data["sale_price"] # Uses property setter
if "cost" in data:
product.cost = data["cost"] # Uses property setter
# Update other allowed fields
updatable_fields = [
"vendor_sku",
"brand",
"gtin",
"gtin_type",
"currency",
"tax_rate_percent",
"availability",
"is_digital",
"is_active",
"is_featured",
"primary_image_url",
"additional_images",
"supplier",
]
for field in updatable_fields:
if field in data:
setattr(product, field, data[field])
db.flush()
logger.info(f"Updated vendor product {product_id}")
return product
def remove_product(self, db: Session, product_id: int) -> dict:
"""Remove a product from vendor catalog."""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise ProductNotFoundException(product_id)
vendor_name = product.vendor.name if product.vendor else "Unknown"
db.delete(product)
db.flush()
logger.info(f"Removed product {product_id} from vendor {vendor_name} catalog")
return {"message": f"Product removed from {vendor_name}'s catalog"}
def _build_product_list_item(self, product: Product, language: str) -> dict:
"""Build a product list item dict."""
mp = product.marketplace_product
# Get title: prefer vendor translations, fallback to marketplace translations
title = None
# First try vendor's own translations
if product.translations:
for trans in product.translations:
if trans.language == language and trans.title:
title = trans.title
break
# Fallback to English if requested language not found
if not title:
for trans in product.translations:
if trans.language == "en" and trans.title:
title = trans.title
break
# Fallback to marketplace translations
if not title and mp:
title = mp.get_title(language)
return {
"id": product.id,
"vendor_id": product.vendor_id,
"vendor_name": product.vendor.name if product.vendor else None,
"vendor_code": product.vendor.vendor_code if product.vendor else None,
"marketplace_product_id": product.marketplace_product_id,
"vendor_sku": product.vendor_sku,
"title": title,
"brand": product.brand,
"price": product.price,
"currency": product.currency,
# Effective price/currency for UI (same as price/currency for now)
"effective_price": product.price,
"effective_currency": product.currency,
"is_active": product.is_active,
"is_featured": product.is_featured,
"is_digital": product.is_digital,
"image_url": product.primary_image_url,
"source_marketplace": mp.marketplace if mp else None,
"source_vendor": mp.vendor_name if mp else None,
"created_at": product.created_at.isoformat()
if product.created_at
else None,
"updated_at": product.updated_at.isoformat()
if product.updated_at
else None,
}
# Create service instance
vendor_product_service = VendorProductService()

View File

@@ -0,0 +1,512 @@
{# app/templates/admin/vendor-product-create.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/modals.html' import media_picker_modal %}
{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %}
{% block title %}Create Vendor Product{% endblock %}
{% block alpine_data %}adminVendorProductCreate(){% endblock %}
{% block quill_css %}
{{ quill_css() }}
{% endblock %}
{% block quill_script %}
{{ quill_js() }}
{% endblock %}
{% block extra_head %}
<!-- Tom Select CSS with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
/>
<style>
/* Tom Select dark mode overrides */
.dark .ts-wrapper .ts-control {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input {
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input::placeholder {
color: rgb(156 163 175);
}
.dark .ts-dropdown {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-dropdown .option {
color: rgb(209 213 219);
}
.dark .ts-dropdown .option.active {
background-color: rgb(147 51 234);
color: white;
}
.dark .ts-dropdown .option:hover {
background-color: rgb(75 85 99);
}
.dark .ts-wrapper.focus .ts-control {
border-color: rgb(147 51 234);
box-shadow: 0 0 0 1px rgb(147 51 234);
}
</style>
{% endblock %}
{% block content %}
{% call detail_page_header("'Create Vendor Product'", '/admin/vendor-products') %}
<span>Add a new product to a vendor's catalog</span>
{% endcall %}
<!-- Create Form -->
<form @submit.prevent="createProduct()">
<!-- Vendor Selection -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Vendor
</h3>
<div class="max-w-md">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Select Vendor <span class="text-red-500">*</span></label>
<select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">The vendor whose catalog this product will be added to</p>
</div>
</div>
<!-- Product Information (Translations) -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Information <span class="text-red-500">*</span>
</h3>
<!-- Language Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
<nav class="flex space-x-4">
<template x-for="lang in ['en', 'fr', 'de', 'lu']" :key="lang">
<button
type="button"
@click="activeLanguage = lang"
:class="activeLanguage === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400'"
class="py-2 px-1 border-b-2 font-medium text-sm uppercase"
x-text="lang"
></button>
</template>
</nav>
</div>
<!-- Translation Fields - English -->
<div x-show="activeLanguage === 'en'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (EN) <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.translations.en.title"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='create-desc-editor-en',
model='form.translations.en.description',
label='Description (EN)',
placeholder='Enter product description in English...',
min_height='150px',
toolbar='standard'
) }}
</div>
<!-- Translation Fields - French -->
<div x-show="activeLanguage === 'fr'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (FR)
</label>
<input
type="text"
x-model="form.translations.fr.title"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='create-desc-editor-fr',
model='form.translations.fr.description',
label='Description (FR)',
placeholder='Enter product description in French...',
min_height='150px',
toolbar='standard'
) }}
</div>
<!-- Translation Fields - German -->
<div x-show="activeLanguage === 'de'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (DE)
</label>
<input
type="text"
x-model="form.translations.de.title"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='create-desc-editor-de',
model='form.translations.de.description',
label='Description (DE)',
placeholder='Enter product description in German...',
min_height='150px',
toolbar='standard'
) }}
</div>
<!-- Translation Fields - Luxembourgish -->
<div x-show="activeLanguage === 'lu'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (LU)
</label>
<input
type="text"
x-model="form.translations.lu.title"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='create-desc-editor-lu',
model='form.translations.lu.description',
label='Description (LU)',
placeholder='Enter product description in Luxembourgish...',
min_height='150px',
toolbar='standard'
) }}
</div>
</div>
<!-- Product Identifiers -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Identifiers
</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Vendor SKU</label>
<div class="flex gap-2">
<input
type="text"
x-model="form.vendor_sku"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
placeholder="XXXX_XXXX_XXXX"
/>
<button
type="button"
@click="generateSku()"
class="px-3 py-2 text-xs font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20"
title="Auto-generate SKU"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Brand</label>
<input
type="text"
x-model="form.brand"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Brand name"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN/EAN</label>
<input
type="text"
x-model="form.gtin"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
placeholder="4007817144145"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN Type</label>
<select
x-model="form.gtin_type"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Not specified</option>
<option value="ean13">EAN-13</option>
<option value="ean8">EAN-8</option>
<option value="upc">UPC</option>
<option value="isbn">ISBN</option>
</select>
</div>
</div>
</div>
<!-- Pricing -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing
</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{# noqa: FE-008 - Using raw number input for price with EUR prefix #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price (incl. VAT)</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
<input
type="number"
step="0.01"
min="0"
x-model.number="form.price"
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Sale Price</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
<input
type="number"
step="0.01"
min="0"
x-model.number="form.sale_price"
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Currency</label>
<select
x-model="form.currency"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">VAT Rate</label>
<select
x-model.number="form.tax_rate_percent"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="17">17% (Standard)</option>
<option value="14">14% (Intermediate)</option>
<option value="8">8% (Reduced)</option>
<option value="3">3% (Super-reduced)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label>
<select
x-model="form.availability"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Not specified</option>
<option value="in_stock">In Stock</option>
<option value="out_of_stock">Out of Stock</option>
<option value="preorder">Preorder</option>
<option value="backorder">Backorder</option>
</select>
</div>
</div>
</div>
<!-- Product Images -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Images
</h3>
<!-- Main Image -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Main Image</label>
<div class="flex items-start gap-4">
<!-- Preview -->
<div class="w-32 h-32 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex-shrink-0">
<template x-if="form.primary_image_url">
<div class="relative w-full h-full group">
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
@click="clearMainImage()"
class="p-2 bg-red-500 rounded-full text-white hover:bg-red-600"
title="Remove image"
>
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div>
</div>
</template>
<template x-if="!form.primary_image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-10 h-10 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Actions -->
<div class="flex flex-col gap-2">
<button
type="button"
@click="openMediaPickerMain()"
:disabled="!form.vendor_id"
class="flex items-center px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span x-html="$icon('photograph', 'w-4 h-4 mr-2')"></span>
Browse Media Library
</button>
<p class="text-xs text-gray-500 dark:text-gray-400">Or enter URL directly:</p>
<input
type="text"
x-model="form.primary_image_url"
class="w-64 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="https://... or /uploads/..."
/>
</div>
</div>
</div>
<!-- Additional Images -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Additional Images</label>
<div class="flex flex-wrap gap-3">
<!-- Existing Additional Images -->
<template x-for="(imgUrl, index) in form.additional_images" :key="index">
<div class="relative w-24 h-24 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 group">
<img :src="imgUrl" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
@click="removeAdditionalImage(index)"
class="p-1.5 bg-red-500 rounded-full text-white hover:bg-red-600"
title="Remove image"
>
<span x-html="$icon('close', 'w-3 h-3')"></span>
</button>
</div>
<div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs text-center py-0.5" x-text="index + 1"></div>
</div>
</template>
<!-- Add Image Button -->
<button
type="button"
@click="openMediaPickerAdditional()"
:disabled="!form.vendor_id"
class="w-24 h-24 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-purple-400 dark:hover:border-purple-500 flex flex-col items-center justify-center text-gray-400 hover:text-purple-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-html="$icon('plus', 'w-6 h-6')"></span>
<span class="text-xs mt-1">Add</span>
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Click "Add" to select images from the media library or upload new ones</p>
</div>
</div>
<!-- Product Type & Status -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Type & Status
</h3>
<div class="flex flex-wrap gap-6">
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_digital"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_active"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_featured"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
</label>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
<a
href="/admin/vendor-products"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</a>
<button
type="submit"
:disabled="saving || !form.vendor_id || !form.translations.en.title"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? 'Creating...' : 'Create Product'"></span>
</button>
</div>
</form>
<!-- Media Picker Modal for Main Image -->
{{ media_picker_modal(
id='mediaPickerMain',
show_var='showMediaPicker',
vendor_id_var='form.vendor_id',
on_select='setMainImage',
multi_select=false,
title='Select Main Image'
) }}
<!-- Media Picker Modal for Additional Images -->
{{ media_picker_modal(
id='mediaPickerAdditional',
show_var='showMediaPickerAdditional',
vendor_id_var='form.vendor_id',
on_select='addAdditionalImages',
multi_select=true,
title='Select Additional Images'
) }}
{% endblock %}
{% block extra_scripts %}
<!-- Tom Select JS with local fallback -->
<script>
(function() {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
script.onerror = function() {
console.warn('Tom Select CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<script src="{{ url_for('cms_static', path='shared/js/media-picker.js') }}"></script>
<script src="{{ url_for('catalog_static', path='admin/js/product-create.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,358 @@
{# app/templates/admin/vendor-product-detail.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Vendor Product Details{% endblock %}
{% block alpine_data %}adminVendorProductDetail(){% endblock %}
{% block content %}
{% call detail_page_header("product?.title || 'Product Details'", '/admin/vendor-products', subtitle_show='product') %}
<span x-text="product?.vendor_name || 'Unknown Vendor'"></span>
<span class="text-gray-400 mx-2">|</span>
<span x-text="product?.vendor_code || ''"></span>
{% endcall %}
{{ loading_state('Loading product details...') }}
{{ error_state('Error loading product') }}
<!-- Product Details -->
<div x-show="!loading && product">
<!-- Info Banner - adapts based on whether product has marketplace source -->
<div class="px-4 py-3 mb-6 rounded-lg shadow-md"
:class="product?.marketplace_product_id ? 'bg-purple-50 dark:bg-purple-900/20' : 'bg-blue-50 dark:bg-blue-900/20'">
<div class="flex items-start">
<span x-html="$icon('information-circle', product?.marketplace_product_id ? 'w-5 h-5 text-purple-500 mr-3 mt-0.5 flex-shrink-0' : 'w-5 h-5 text-blue-500 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<!-- Marketplace-sourced product -->
<template x-if="product?.marketplace_product_id">
<div>
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Vendor Product Catalog Entry</p>
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">
This is a vendor-specific copy of a marketplace product. All fields are independently managed.
View the source product for comparison.
</p>
</div>
</template>
<!-- Directly created product -->
<template x-if="!product?.marketplace_product_id">
<div>
<p class="text-sm font-medium text-blue-700 dark:text-blue-300">Directly Created Product</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
This product was created directly for this vendor without a marketplace source.
All product information is managed independently.
</p>
</div>
</template>
</div>
</div>
</div>
<!-- Quick Actions Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h3>
<div class="flex flex-wrap items-center gap-3">
<!-- View Source Product - only for marketplace-sourced products -->
<a
x-show="product?.marketplace_product_id"
:href="'/admin/marketplace-products/' + product?.marketplace_product_id"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('database', 'w-4 h-4 mr-2')"></span>
View Source Product
</a>
<a
:href="'/admin/vendor-products/' + productId + '/edit'"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
<span x-html="$icon('pencil', 'w-4 h-4 mr-2')"></span>
<span x-text="product?.marketplace_product_id ? 'Edit Overrides' : 'Edit Product'"></span>
</a>
<button
@click="toggleActive()"
:class="product?.is_active
? 'text-red-700 dark:text-red-300 border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
: 'text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 transition-colors duration-150 bg-white dark:bg-gray-700 border rounded-lg">
<span x-html="$icon(product?.is_active ? 'x-circle' : 'check-circle', 'w-4 h-4 mr-2')"></span>
<span x-text="product?.is_active ? 'Deactivate' : 'Activate'"></span>
</button>
<button
@click="confirmRemove()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-red-600 dark:text-red-400 transition-colors duration-150 bg-white dark:bg-gray-700 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20">
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
Remove from Catalog
</button>
</div>
</div>
<!-- Product Header with Image -->
<div class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Product Image -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden">
<template x-if="product?.image_url">
<img :src="product?.image_url" :alt="product?.title" class="w-full h-full object-contain" />
</template>
<template x-if="!product?.image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-16 h-16 text-gray-300')"></span>
</div>
</template>
</div>
<!-- Additional Images -->
<div x-show="product?.additional_images?.length > 0" class="mt-4">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Additional Images</p>
<div class="grid grid-cols-4 gap-2">
<template x-for="(img, index) in (product?.additional_images || [])" :key="index">
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded overflow-hidden">
<img :src="img" :alt="'Image ' + (index + 1)" class="w-full h-full object-cover" />
</div>
</template>
</div>
</div>
</div>
<!-- Product Info -->
<div class="md:col-span-2 space-y-6">
<!-- Vendor Info Card -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Vendor Information
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="product?.vendor_name || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor Code</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_code || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor SKU</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_sku || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Status</p>
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="product?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-900/30 dark:text-green-400' : 'text-red-700 bg-red-100 dark:bg-red-900/30 dark:text-red-400'"
x-text="product?.is_active ? 'Active' : 'Inactive'">
</span>
<span x-show="product?.is_featured" class="px-2 py-1 text-xs font-semibold rounded-full text-yellow-700 bg-yellow-100 dark:bg-yellow-900/30 dark:text-yellow-400">
Featured
</span>
</div>
</div>
</div>
</div>
<!-- Pricing Card -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing
</h3>
<div class="grid gap-4 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Price</p>
<p class="text-lg font-bold text-gray-700 dark:text-gray-200" x-text="formatPrice(product?.effective_price, product?.effective_currency)">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Availability</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.availability || 'Not specified'">-</p>
</div>
</div>
</div>
</div>
</div>
<!-- Product Information Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Information
</h3>
<div class="grid gap-4 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Brand</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.brand || 'No brand'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product Type</p>
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="product?.is_digital ? 'text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400' : 'text-orange-700 bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400'"
x-text="product?.is_digital ? 'Digital' : 'Physical'">
</span>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Condition</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.condition || 'Not specified'">-</p>
</div>
</div>
</div>
<!-- Product Identifiers Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Identifiers
</h3>
<div class="grid gap-4" :class="product?.marketplace_product_id ? 'md:grid-cols-4' : 'md:grid-cols-3'">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product ID</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.id || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">GTIN/EAN</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.gtin || product?.source_gtin || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor SKU</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_sku || '-'">-</p>
</div>
<!-- Source SKU - only for marketplace-sourced products -->
<div x-show="product?.marketplace_product_id">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source SKU</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.source_sku || '-'">-</p>
</div>
</div>
</div>
<!-- Source Information Card - only for marketplace-sourced products -->
<template x-if="product?.marketplace_product_id">
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Source Information
</h3>
<a
:href="'/admin/marketplace-products/' + product?.marketplace_product_id"
class="flex items-center text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
<span>View Source</span>
<span x-html="$icon('arrow-right', 'w-4 h-4 ml-1')"></span>
</a>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_marketplace || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source Vendor</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_vendor || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace Product ID</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.marketplace_product_id || '-'">-</p>
</div>
</div>
</div>
</template>
<!-- Product Origin Card - for directly created products -->
<template x-if="!product?.marketplace_product_id">
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Origin
</h3>
<div class="flex items-center gap-3">
<span class="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30">
<span x-html="$icon('plus-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
</span>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Direct Creation</p>
<p class="text-xs text-gray-500 dark:text-gray-400">This product was created directly in the vendor's catalog</p>
</div>
</div>
</div>
</template>
<!-- Description Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.title || product?.description">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Content
</h3>
<div class="space-y-4">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Title</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.title || '-'">-</p>
</div>
<div x-show="product?.description">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Description</p>
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none" x-html="product?.description || '-'"></div>
</div>
</div>
</div>
<!-- Category Information -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.google_product_category || product?.category_path">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Categories
</h3>
<div class="space-y-3">
<div x-show="product?.google_product_category">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Google Product Category</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.google_product_category">-</p>
</div>
<div x-show="product?.category_path">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Category Path</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.category_path">-</p>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Record Information
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Added to Catalog</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.created_at)">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Last Updated</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.updated_at)">-</p>
</div>
</div>
</div>
</div>
<!-- Confirm Remove Modal -->
{% call modal_simple('confirmRemoveModal', 'Remove from Catalog', show_var='showRemoveModal', size='sm') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to remove this product from the vendor's catalog?
</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="product?.title || 'Untitled'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">
This will not delete the source product from the marketplace repository.
</p>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showRemoveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
@click="executeRemove()"
:disabled="removing"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
<span x-text="removing ? 'Removing...' : 'Remove'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('catalog_static', path='admin/js/product-detail.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,503 @@
{# app/templates/admin/vendor-product-edit.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/modals.html' import media_picker_modal %}
{% from 'shared/macros/richtext.html' import quill_css, quill_js, quill_editor %}
{% block title %}Edit Vendor Product{% endblock %}
{% block alpine_data %}adminVendorProductEdit(){% endblock %}
{% block quill_css %}
{{ quill_css() }}
{% endblock %}
{% block quill_script %}
{{ quill_js() }}
{% endblock %}
{% block content %}
{% call detail_page_header("'Edit: ' + (product?.vendor_translations?.en?.title || 'Product')", '/admin/vendor-products', subtitle_show='product') %}
<span x-text="product?.vendor_name || 'Unknown Vendor'"></span>
{% endcall %}
{{ loading_state('Loading product...') }}
{{ error_state('Error loading product') }}
<!-- Edit Form -->
<div x-show="!loading && product" x-cloak>
<form @submit.prevent="saveProduct()">
<!-- Translations (Tabbed) -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Information <span class="text-red-500">*</span>
</h3>
<!-- Language Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
<nav class="flex space-x-4">
<template x-for="lang in ['en', 'fr', 'de', 'lu']" :key="lang">
<button
type="button"
@click="activeLanguage = lang"
:class="activeLanguage === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400'"
class="py-2 px-1 border-b-2 font-medium text-sm uppercase"
x-text="lang"
></button>
</template>
</nav>
</div>
<!-- Translation Fields - English -->
<div x-show="activeLanguage === 'en'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (EN) <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.translations.en.title"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='desc-editor-en',
model='form.translations.en.description',
label='Description (EN)',
required=true,
placeholder='Enter product description in English...',
min_height='150px',
toolbar='standard'
) }}
</div>
<!-- Translation Fields - French -->
<div x-show="activeLanguage === 'fr'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (FR)
</label>
<input
type="text"
x-model="form.translations.fr.title"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='desc-editor-fr',
model='form.translations.fr.description',
label='Description (FR)',
placeholder='Enter product description in French...',
min_height='150px',
toolbar='standard'
) }}
</div>
<!-- Translation Fields - German -->
<div x-show="activeLanguage === 'de'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (DE)
</label>
<input
type="text"
x-model="form.translations.de.title"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='desc-editor-de',
model='form.translations.de.description',
label='Description (DE)',
placeholder='Enter product description in German...',
min_height='150px',
toolbar='standard'
) }}
</div>
<!-- Translation Fields - Luxembourgish -->
<div x-show="activeLanguage === 'lu'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (LU)
</label>
<input
type="text"
x-model="form.translations.lu.title"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
{{ quill_editor(
id='desc-editor-lu',
model='form.translations.lu.description',
label='Description (LU)',
placeholder='Enter product description in Luxembourgish...',
min_height='150px',
toolbar='standard'
) }}
</div>
</div>
<!-- Product Identifiers -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Identifiers
</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Vendor SKU <span class="text-red-500">*</span>
</label>
<div class="flex gap-2">
<input
type="text"
x-model="form.vendor_sku"
required
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
placeholder="XXXX_XXXX_XXXX"
/>
<button
type="button"
@click="generateSku()"
class="px-3 py-2 text-xs font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20"
title="Auto-generate SKU"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Brand <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.brand"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Brand name"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
GTIN/EAN <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.gtin"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
placeholder="4007817144145"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
GTIN Type <span class="text-red-500">*</span>
</label>
<select
x-model="form.gtin_type"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="ean13">EAN-13</option>
<option value="ean8">EAN-8</option>
<option value="upc">UPC</option>
<option value="isbn">ISBN</option>
</select>
</div>
</div>
</div>
<!-- Pricing -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing
</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{# noqa: FE-008 - Using raw number input for price with EUR prefix #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Price (incl. VAT) <span class="text-red-500">*</span>
</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
<input
type="number"
step="0.01"
min="0"
x-model.number="form.price"
required
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Sale Price (optional)
</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
<input
type="number"
step="0.01"
min="0"
x-model.number="form.sale_price"
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Currency <span class="text-red-500">*</span>
</label>
<select
x-model="form.currency"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
VAT Rate <span class="text-red-500">*</span>
</label>
<select
x-model.number="form.tax_rate_percent"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="17">17% (Standard)</option>
<option value="14">14% (Intermediate)</option>
<option value="8">8% (Reduced)</option>
<option value="3">3% (Super-reduced)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Availability
</label>
<select
x-model="form.availability"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Not specified</option>
<option value="in_stock">In Stock</option>
<option value="out_of_stock">Out of Stock</option>
<option value="preorder">Preorder</option>
<option value="backorder">Backorder</option>
</select>
</div>
</div>
</div>
<!-- Images -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Images
</h3>
<!-- Main Image -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Main Image <span class="text-red-500">*</span>
</label>
<div class="flex items-start gap-4">
<!-- Preview -->
<div class="w-32 h-32 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 flex-shrink-0">
<template x-if="form.primary_image_url">
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
</template>
<template x-if="!form.primary_image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-10 h-10 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Actions -->
<div class="flex-1 space-y-2">
<div class="flex gap-2">
<button
type="button"
@click="openMediaPickerMain(); loadMediaLibrary()"
class="px-3 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 flex items-center gap-2"
>
<span x-html="$icon('photograph', 'w-4 h-4')"></span>
Browse Media
</button>
<button
type="button"
x-show="form.primary_image_url"
@click="clearMainImage()"
class="px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
>
Remove
</button>
</div>
<!-- URL fallback -->
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Or enter URL directly:</label>
<input
type="text"
x-model="form.primary_image_url"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="https://... or /uploads/..."
/>
</div>
</div>
</div>
</div>
<!-- Additional Images -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Additional Images
</label>
<div class="grid grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-3 mb-3">
<!-- Existing additional images -->
<template x-for="(url, index) in form.additional_images" :key="index">
<div class="relative group">
<div class="w-full aspect-square rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600">
<img :src="url" class="w-full h-full object-cover" />
</div>
<button
type="button"
@click="removeAdditionalImage(index)"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</template>
<!-- Add button -->
<button
type="button"
@click="openMediaPickerAdditional(); loadMediaLibrary()"
class="w-full aspect-square rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-purple-400 dark:hover:border-purple-500 flex items-center justify-center text-gray-400 hover:text-purple-500 transition-colors"
>
<span x-html="$icon('plus', 'w-8 h-8')"></span>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Click the + button to add more images from the media library
</p>
</div>
</div>
<!-- Media Picker Modals -->
{{ media_picker_modal(
id='media-picker-main',
show_var='showMediaPicker',
vendor_id_var='product?.vendor_id',
title='Select Main Image'
) }}
{{ media_picker_modal(
id='media-picker-additional',
show_var='showMediaPickerAdditional',
vendor_id_var='product?.vendor_id',
multi_select=true,
title='Select Additional Images'
) }}
<!-- Product Type & Status -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Type & Status
</h3>
<div class="flex flex-wrap gap-6">
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_digital"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_active"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_featured"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
</label>
</div>
</div>
<!-- Optional: Supplier Info -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Supplier Info (Optional)
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Supplier</label>
<input
type="text"
x-model="form.supplier"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Supplier name"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Cost (what you pay)</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
<input
type="number"
step="0.01"
min="0"
x-model.number="form.cost"
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
<a
:href="'/admin/vendor-products/' + productId"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</a>
<button
type="submit"
:disabled="saving || !isFormValid()"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
</button>
</div>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('cms_static', path='shared/js/media-picker.js') }}"></script>
<script src="{{ url_for('catalog_static', path='admin/js/product-edit.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,400 @@
{# app/templates/admin/vendor-products.html #}
{% extends "admin/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/tables.html' import table_wrapper %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Vendor Products{% endblock %}
{% block alpine_data %}adminVendorProducts(){% endblock %}
{% block extra_head %}
<!-- Tom Select CSS with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
/>
<style>
/* Tom Select dark mode overrides */
.dark .ts-wrapper .ts-control {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input {
color: rgb(209 213 219);
}
.dark .ts-wrapper .ts-control input::placeholder {
color: rgb(156 163 175);
}
.dark .ts-dropdown {
background-color: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(209 213 219);
}
.dark .ts-dropdown .option {
color: rgb(209 213 219);
}
.dark .ts-dropdown .option.active {
background-color: rgb(147 51 234);
color: white;
}
.dark .ts-dropdown .option:hover {
background-color: rgb(75 85 99);
}
.dark .ts-wrapper.focus .ts-control {
border-color: rgb(147 51 234);
box-shadow: 0 0 0 1px rgb(147 51 234);
}
</style>
{% endblock %}
{% block content %}
<!-- Page Header with Vendor Selector -->
{% call page_header_flex(title='Vendor Products', subtitle='Browse vendor-specific product catalogs with override capability') %}
<div class="flex items-center gap-4">
<!-- Vendor Autocomplete (Tom Select) -->
<div class="w-80">
<select id="vendor-select" x-ref="vendorSelect" placeholder="Filter by vendor...">
</select>
</div>
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
<a
href="/admin/vendor-products/create"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Product
</a>
</div>
{% endcall %}
<!-- Selected Vendor Info -->
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
</div>
<div>
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
</div>
</div>
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
<span x-html="$icon('x', 'w-4 h-4')"></span>
Clear filter
</button>
</div>
</div>
{{ loading_state('Loading products...') }}
{{ error_state('Error loading products') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-5">
<!-- Card: Total Products -->
<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('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Products
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total || 0">
0
</p>
</div>
</div>
<!-- Card: Active Products -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active || 0">
0
</p>
</div>
</div>
<!-- Card: Featured Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('star', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Featured
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.featured || 0">
0
</p>
</div>
</div>
<!-- Card: Digital Products -->
<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('code', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Digital
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.digital || 0">
0
</p>
</div>
</div>
<!-- Card: Physical Products -->
<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('truck', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Physical
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.physical || 0">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-xl">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search by title or vendor SKU..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Status Filter -->
<select
x-model="filters.is_active"
@change="pagination.page = 1; loadProducts()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
<!-- Featured Filter -->
<select
x-model="filters.is_featured"
@change="pagination.page = 1; loadProducts()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Products</option>
<option value="true">Featured Only</option>
<option value="false">Not Featured</option>
</select>
</div>
</div>
</div>
<!-- Products Table with Pagination -->
<div x-show="!loading">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Source</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="products.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('cube', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No vendor products found</p>
<p class="text-xs mt-1" x-text="filters.search || filters.vendor_id || filters.is_active ? 'Try adjusting your filters' : 'Copy products from the Marketplace Products page'"></p>
</div>
</td>
</tr>
</template>
<!-- Product Rows -->
<template x-for="product in products" :key="product.id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Product Info -->
<td class="px-4 py-3">
<div class="flex items-center">
<!-- Product Image -->
<div class="w-12 h-12 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
<template x-if="product.image_url">
<img :src="product.image_url" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
</template>
<template x-if="!product.image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-6 h-6 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Product Details -->
<div class="min-w-0">
<p class="font-semibold text-sm truncate max-w-xs" x-text="product.title || 'Untitled'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
<template x-if="product.vendor_sku">
<p class="text-xs text-gray-400 font-mono">SKU: <span x-text="product.vendor_sku"></span></p>
</template>
<template x-if="product.is_digital">
<span class="inline-flex items-center px-2 py-0.5 mt-1 text-xs font-medium text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 rounded">
<span x-html="$icon('code', 'w-3 h-3 mr-1')"></span>
Digital
</span>
</template>
</div>
</div>
</td>
<!-- Vendor Info -->
<td class="px-4 py-3 text-sm">
<p class="font-medium" x-text="product.vendor_name || 'Unknown'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono" x-text="product.vendor_code || ''"></p>
</td>
<!-- Source (Marketplace) -->
<td class="px-4 py-3 text-sm">
<p x-text="product.source_marketplace || 'Unknown'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[120px]" x-text="'from ' + (product.source_vendor || 'Unknown')"></p>
</td>
<!-- Price -->
<td class="px-4 py-3 text-sm">
<template x-if="product.effective_price">
<p class="font-medium" x-text="formatPrice(product.effective_price, product.effective_currency)"></p>
</template>
<template x-if="!product.effective_price">
<p class="text-gray-400">-</p>
</template>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<div class="flex flex-col gap-1">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs w-fit"
:class="product.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="product.is_active ? 'Active' : 'Inactive'">
</span>
<template x-if="product.is_featured">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs w-fit text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100">
Featured
</span>
</template>
</div>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<a
:href="'/admin/vendor-products/' + product.id"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="View"
>
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</a>
<a
:href="'/admin/vendor-products/' + product.id + '/edit'"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-blue-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Edit"
>
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</a>
<button
@click="confirmRemove(product)"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-red-600 rounded-lg dark:text-red-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Delete"
>
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination(show_condition="!loading && pagination.total > 0") }}
</div>
<!-- Confirm Remove Modal -->
{% call modal_simple('confirmRemoveModal', 'Remove from Catalog', show_var='showRemoveModal', size='sm') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to remove this product from the vendor's catalog?
</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="productToRemove?.title || 'Untitled'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400">
This will not delete the source product from the marketplace repository.
</p>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showRemoveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
@click="executeRemove()"
:disabled="removing"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
<span x-text="removing ? 'Removing...' : 'Remove'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<!-- Tom Select JS with local fallback -->
<script>
(function() {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
script.onerror = function() {
console.warn('Tom Select CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<script src="{{ url_for('catalog_static', path='admin/js/products.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,281 @@
{# app/modules/catalog/templates/catalog/storefront/category.html #}
{% extends "storefront/base.html" %}
{% block title %}{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopCategory(){% endblock %}
{% block content %}
<div class="max-w-7xl 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>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="categoryName">{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}</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-2" x-text="categoryName">
{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}
</h1>
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && total > 0">
<span x-text="total" class="font-semibold"></span> product<span x-show="total !== 1">s</span> in this category
</p>
</div>
{# Sort Bar #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="text-sm text-gray-600 dark:text-gray-400">
Showing <span x-text="products.length" class="font-semibold"></span> of <span x-text="total" class="font-semibold"></span> products
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Sort by:</label>
<select
x-model="sortBy"
@change="loadProducts()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
>
<option value="newest">Newest First</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="popular">Most Popular</option>
</select>
</div>
</div>
</div>
{# Products Grid #}
<div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Products Grid #}
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(product.price)"></span>
<span x-show="product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(product.sale_price)"></span>
</div>
<button @click.prevent="addToCart(product)"
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2"
style="background-color: var(--color-primary)"
:title="'Add to Cart'">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="hidden sm:inline">Add to Cart</span>
</button>
</div>
</div>
</div>
</template>
</div>
{# No Products Message #}
<div x-show="!loading && products.length === 0" class="col-span-full text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">📦</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Products in This Category
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Check back later or browse other categories.
</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Browse All Products
</a>
</div>
{# Pagination #}
<div x-show="!loading && totalPages > 1" class="mt-8 flex justify-center">
<div class="flex gap-2">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<template x-for="page in visiblePages" :key="page">
<button
@click="goToPage(page)"
:class="page === currentPage ? 'bg-primary text-white' : 'border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="px-4 py-2 rounded-lg"
:style="page === currentPage ? 'background-color: var(--color-primary)' : ''"
x-text="page"
></button>
</template>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Pass category slug from template to JavaScript
window.CATEGORY_SLUG = '{{ category_slug | default("") }}';
document.addEventListener('alpine:init', () => {
Alpine.data('shopCategory', () => ({
...shopLayoutData(),
// Data
categorySlug: window.CATEGORY_SLUG,
categoryName: '',
products: [],
total: 0,
loading: true,
sortBy: 'newest',
// Pagination
currentPage: 1,
perPage: 12,
get totalPages() {
return Math.ceil(this.total / this.perPage);
},
get visiblePages() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
let start = Math.max(1, current - 2);
let end = Math.min(total, current + 2);
if (end - start < 4) {
if (start === 1) {
end = Math.min(total, 5);
} else {
start = Math.max(1, total - 4);
}
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
async init() {
console.log('[SHOP] Category page initializing...');
console.log('[SHOP] Category slug:', this.categorySlug);
// Convert slug to display name
this.categoryName = this.categorySlug
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
await this.loadProducts();
},
async loadProducts() {
this.loading = true;
try {
const params = new URLSearchParams({
skip: (this.currentPage - 1) * this.perPage,
limit: this.perPage,
category: this.categorySlug
});
if (this.sortBy) {
params.append('sort', this.sortBy);
}
console.log(`[SHOP] Loading category products from /api/v1/shop/products?${params}`);
const response = await fetch(`/api/v1/shop/products?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
this.products = data.products;
this.total = data.total;
} catch (error) {
console.error('[SHOP] Failed to load category products:', error);
this.showToast('Failed to load products', 'error');
} finally {
this.loading = false;
}
},
async goToPage(page) {
if (page < 1 || page > this.totalPages) return;
this.currentPage = page;
await this.loadProducts();
window.scrollTo({ top: 0, behavior: 'smooth' });
},
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}
}));
});
</script>
{% endblock %}

View File

@@ -0,0 +1,426 @@
{# app/modules/catalog/templates/catalog/storefront/product.html #}
{% extends "storefront/base.html" %}
{% block title %}{{ product.name if product else 'Product' }}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}productDetail(){% endblock %}
{% block content %}
<div class="max-w-7xl 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>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
</div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Product Detail #}
<div x-show="!loading && product" class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
{# Product Images #}
<div class="product-images">
<div class="main-image bg-white dark:bg-gray-800 rounded-lg overflow-hidden mb-4">
<img
:src="selectedImage || '/static/shop/img/placeholder.svg'"
@error="selectedImage = '/static/shop/img/placeholder.svg'"
:alt="product?.marketplace_product?.title"
class="w-full h-auto object-contain"
style="max-height: 600px;"
>
</div>
{# Thumbnail Gallery #}
<div x-show="product?.marketplace_product?.images?.length > 1" class="grid grid-cols-4 gap-2">
<template x-for="(image, index) in product?.marketplace_product?.images" :key="index">
<img
:src="image"
:alt="`Product image ${index + 1}`"
class="w-full aspect-square object-cover rounded-lg cursor-pointer border-2 transition-all"
:class="selectedImage === image ? 'border-primary' : 'border-transparent hover:border-gray-300'"
@click="selectedImage = image"
>
</template>
</div>
</div>
{# Product Info #}
<div class="product-info-detail">
<h1 x-text="product?.marketplace_product?.title" class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
Product
</h1>
{# Brand & Category #}
<div class="flex flex-wrap gap-4 mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<span x-show="product?.marketplace_product?.brand" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Brand:</strong> <span x-text="product?.marketplace_product?.brand"></span>
</span>
<span x-show="product?.marketplace_product?.google_product_category" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Category:</strong> <span x-text="product?.marketplace_product?.google_product_category"></span>
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
<strong>SKU:</strong> <span x-text="product?.product_id || product?.marketplace_product?.mpn"></span>
</span>
</div>
{# Price #}
<div class="mb-6">
<div x-show="product?.sale_price && product?.sale_price < product?.price">
<span class="text-xl text-gray-500 line-through mr-3"><span x-text="parseFloat(product?.price).toFixed(2)"></span></span>
<span class="text-4xl font-bold text-red-600"><span x-text="parseFloat(product?.sale_price).toFixed(2)"></span></span>
<span class="ml-2 inline-block bg-red-600 text-white px-3 py-1 rounded-full text-sm font-semibold">SALE</span>
</div>
<div x-show="!product?.sale_price || product?.sale_price >= product?.price">
<span class="text-4xl font-bold text-gray-800 dark:text-gray-200"><span x-text="parseFloat(product?.price || 0).toFixed(2)"></span></span>
</div>
</div>
{# Availability #}
<div class="mb-6">
<span
x-show="product?.available_inventory > 0"
class="inline-block px-4 py-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-lg font-semibold"
>
✓ In Stock (<span x-text="product?.available_inventory"></span> available)
</span>
<span
x-show="!product?.available_inventory || product?.available_inventory <= 0"
class="inline-block px-4 py-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-lg font-semibold"
>
✗ Out of Stock
</span>
</div>
{# Description #}
<div class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-3">Description</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed" x-text="product?.marketplace_product?.description || 'No description available'"></p>
</div>
{# Additional Details #}
<div x-show="hasAdditionalDetails" class="mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold mb-3">Product Details</h3>
<ul class="space-y-2">
<li x-show="product?.marketplace_product?.gtin" class="text-sm text-gray-600 dark:text-gray-400">
<strong>GTIN:</strong> <span x-text="product?.marketplace_product?.gtin"></span>
</li>
<li x-show="product?.condition" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Condition:</strong> <span x-text="product?.condition"></span>
</li>
<li x-show="product?.marketplace_product?.color" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Color:</strong> <span x-text="product?.marketplace_product?.color"></span>
</li>
<li x-show="product?.marketplace_product?.size" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Size:</strong> <span x-text="product?.marketplace_product?.size"></span>
</li>
<li x-show="product?.marketplace_product?.material" class="text-sm text-gray-600 dark:text-gray-400">
<strong>Material:</strong> <span x-text="product?.marketplace_product?.material"></span>
</li>
</ul>
</div>
{# Add to Cart Section #}
<div class="p-6 bg-white dark:bg-gray-800 rounded-lg border-2 border-primary">
{# Quantity Selector #}
{# noqa: FE-008 - Custom quantity stepper with dynamic product-based min/max and validateQuantity() handler #}
<div class="mb-4">
<label class="block font-semibold text-lg mb-2">Quantity:</label>
<div class="flex items-center gap-2">
<button
@click="decreaseQuantity()"
:disabled="quantity <= (product?.min_quantity || 1)"
class="w-10 h-10 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<input
type="number"
x-model.number="quantity"
:min="product?.min_quantity || 1"
:max="product?.max_quantity || product?.available_inventory"
class="w-20 text-center px-3 py-2 border-2 border-gray-300 dark:border-gray-600 rounded-lg font-semibold dark:bg-gray-700 dark:text-white"
@change="validateQuantity()"
>
<button
@click="increaseQuantity()"
:disabled="quantity >= (product?.max_quantity || product?.available_inventory)"
class="w-10 h-10 flex items-center justify-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
+
</button>
</div>
</div>
{# Add to Cart Button #}
<button
@click="addToCart()"
:disabled="!canAddToCart || addingToCart"
class="w-full px-6 py-4 bg-primary text-white rounded-lg font-semibold text-lg hover:bg-primary-dark transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!addingToCart">
🛒 Add to Cart
</span>
<span x-show="addingToCart">
<span class="inline-block animate-spin"></span> Adding...
</span>
</button>
{# Total Price #}
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-center">
<strong class="text-xl">Total:</strong> <span class="text-2xl font-bold"><span x-text="totalPrice.toFixed(2)"></span></span>
</div>
</div>
</div>
</div>
{# Related Products #}
<div x-show="relatedProducts.length > 0" class="mt-12 pt-12 border-t-2 border-gray-200 dark:border-gray-700">
<h2 class="text-3xl font-bold mb-6">You May Also Like</h2>
<div class="product-grid">
<template x-for="related in relatedProducts" :key="related.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<img
:src="related.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="related.marketplace_product?.title"
class="w-full h-48 object-cover"
>
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="related.marketplace_product?.title"></h3>
</a>
<p class="text-2xl font-bold text-primary">
<span x-text="parseFloat(related.price).toFixed(2)"></span>
</p>
</div>
</div>
</template>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Pass product ID from template to JavaScript
window.PRODUCT_ID = {{ product_id }};
window.VENDOR_ID = {{ vendor.id }};
document.addEventListener('alpine:init', () => {
Alpine.data('productDetail', () => {
const baseData = shopLayoutData();
return {
...baseData,
// Data
product: null,
relatedProducts: [],
loading: false,
addingToCart: false,
quantity: 1,
selectedImage: null,
vendorId: window.VENDOR_ID,
productId: window.PRODUCT_ID,
// Computed properties
get canAddToCart() {
return this.product?.is_active &&
this.product?.available_inventory > 0 &&
this.quantity > 0 &&
this.quantity <= this.product?.available_inventory;
},
get totalPrice() {
const price = this.product?.sale_price || this.product?.price || 0;
return price * this.quantity;
},
get hasAdditionalDetails() {
return this.product?.marketplace_product?.gtin ||
this.product?.condition ||
this.product?.marketplace_product?.color ||
this.product?.marketplace_product?.size ||
this.product?.marketplace_product?.material;
},
// Initialize
async init() {
console.log('[SHOP] Product detail page initializing...');
// Call parent init to set up sessionId
if (baseData.init) {
baseData.init.call(this);
}
console.log('[SHOP] Product ID:', this.productId);
console.log('[SHOP] Vendor ID:', this.vendorId);
console.log('[SHOP] Session ID:', this.sessionId);
await this.loadProduct();
},
// Load product details
async loadProduct() {
this.loading = true;
try {
console.log(`[SHOP] Loading product ${this.productId}...`);
const response = await fetch(`/api/v1/shop/products/${this.productId}`);
if (!response.ok) {
throw new Error('Product not found');
}
this.product = await response.json();
console.log('[SHOP] Product loaded:', this.product);
// Set default image
if (this.product?.marketplace_product?.image_link) {
this.selectedImage = this.product.marketplace_product.image_link;
}
// Set initial quantity
this.quantity = this.product?.min_quantity || 1;
// Load related products
await this.loadRelatedProducts();
} catch (error) {
console.error('[SHOP] Failed to load product:', error);
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
window.location.href = '{{ base_url }}shop/products';
}, 2000);
} finally {
this.loading = false;
}
},
// Load related products
async loadRelatedProducts() {
try {
const response = await fetch(`/api/v1/shop/products?limit=4`);
if (response.ok) {
const data = await response.json();
// Filter out current product
this.relatedProducts = data.products
.filter(p => p.id !== parseInt(this.productId))
.slice(0, 4);
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
}
} catch (error) {
console.error('[SHOP] Failed to load related products:', error);
}
},
// Quantity controls
increaseQuantity() {
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < max) {
this.quantity++;
}
},
decreaseQuantity() {
const min = this.product?.min_quantity || 1;
if (this.quantity > min) {
this.quantity--;
}
},
validateQuantity() {
const min = this.product?.min_quantity || 1;
const max = this.product?.max_quantity || this.product?.available_inventory;
if (this.quantity < min) {
this.quantity = min;
} else if (this.quantity > max) {
this.quantity = max;
}
},
// Add to cart
async addToCart() {
if (!this.canAddToCart) {
console.warn('[SHOP] Cannot add to cart:', {
canAddToCart: this.canAddToCart,
isActive: this.product?.is_active,
inventory: this.product?.available_inventory,
quantity: this.quantity
});
return;
}
this.addingToCart = true;
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: parseInt(this.productId),
quantity: this.quantity
};
console.log('[SHOP] Adding to cart:', {
url,
sessionId: this.sessionId,
productId: this.productId,
quantity: this.quantity,
payload
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
console.log('[SHOP] Add to cart response:', {
status: response.status,
ok: response.ok
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += this.quantity;
this.showToast(
`${this.quantity} item(s) added to cart!`,
'success'
);
// Reset quantity to minimum
this.quantity = this.product?.min_quantity || 1;
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error response:', error);
throw new Error(error.detail || 'Failed to add to cart');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
} finally {
this.addingToCart = false;
}
}
};
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,246 @@
{# app/modules/catalog/templates/catalog/storefront/products.html #}
{% extends "storefront/base.html" %}
{% block title %}Products{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopProducts(){% endblock %}
{% block content %}
<div class="max-w-7xl 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">Products</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-2">
All Products
</h1>
<p class="text-gray-600 dark:text-gray-400">
Discover our complete collection
</p>
</div>
{# Filters & Search Bar #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
{# Search #}
<div class="md:col-span-2">
<input
type="text"
placeholder="Search products..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
>
</div>
{# Category Filter #}
<div>
<select class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">All Categories</option>
<option value="home">Home & Living</option>
<option value="fashion">Fashion</option>
<option value="electronics">Electronics</option>
<option value="arts">Arts & Crafts</option>
</select>
</div>
{# Sort #}
<div>
<select class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="newest">Newest First</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="popular">Most Popular</option>
</select>
</div>
</div>
</div>
{# Products Grid #}
<div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Products Grid #}
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(product.price)"></span>
<span x-show="product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(product.sale_price)"></span>
</div>
<button @click.prevent="addToCart(product)"
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2"
style="background-color: var(--color-primary)"
:title="'Add to Cart'">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="hidden sm:inline">Add to Cart</span>
</button>
</div>
</div>
</div>
</template>
</div>
{# No Products Message #}
<div x-show="!loading && products.length === 0" class="col-span-full text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">📦</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Products Yet
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Products will appear here once they are added to the catalog.
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
<strong>For Developers:</strong> Add products through the vendor dashboard or admin panel.
</p>
</div>
{# Pagination (hidden for now) #}
<div x-show="false" class="mt-8 flex justify-center">
<div class="flex gap-2">
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
Previous
</button>
<button class="px-4 py-2 bg-primary text-white rounded-lg">
1
</button>
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
2
</button>
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
3
</button>
<button class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
Next
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shopProducts', () => ({
...shopLayoutData(),
products: [],
loading: true,
filters: {
search: '',
category: '',
sort: 'newest'
},
pagination: {
page: 1,
perPage: 12,
total: 0
},
async init() {
console.log('[SHOP] Products page initializing...');
await this.loadProducts();
},
async loadProducts() {
this.loading = true;
try {
const params = new URLSearchParams({
skip: (this.pagination.page - 1) * this.pagination.perPage,
limit: this.pagination.perPage
});
// Add search filter if present
if (this.filters.search) {
params.append('search', this.filters.search);
}
console.log(`[SHOP] Loading products from /api/v1/shop/products?${params}`);
const response = await fetch(`/api/v1/shop/products?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
this.products = data.products;
this.pagination.total = data.total;
} catch (error) {
console.error('[SHOP] Failed to load products:', error);
this.showToast('Failed to load products', 'error');
} finally {
this.loading = false;
}
},
filterProducts() {
this.loading = true;
this.loadProducts();
},
// formatPrice is inherited from shopLayoutData() via spread operator
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}
}));
});
</script>
{% endblock %}

View File

@@ -0,0 +1,327 @@
{# app/modules/catalog/templates/catalog/storefront/search.html #}
{# noqa: FE-001 - Shop uses custom pagination with vendor-themed styling (CSS variables) #}
{% extends "storefront/base.html" %}
{% block title %}Search Results{% if query %} for "{{ query }}"{% endif %}{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopSearch(){% endblock %}
{% block content %}
<div class="max-w-7xl 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">Search</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-2">
<span x-show="query">Search Results for "<span x-text="query"></span>"</span>
<span x-show="!query">Search Products</span>
</h1>
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && total > 0">
Found <span x-text="total" class="font-semibold"></span> product<span x-show="total !== 1">s</span>
</p>
</div>
{# Search Bar #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
<form @submit.prevent="performSearch" class="flex gap-4">
<div class="flex-1 relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
<span x-html="$icon('search', 'w-5 h-5')"></span>
</span>
<input
type="text"
x-model="searchInput"
placeholder="Search products by name, description, SKU..."
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"
autofocus
>
</div>
<button
type="submit"
class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center gap-2"
style="background-color: var(--color-primary)"
:disabled="searching"
>
<span x-show="!searching" x-html="$icon('search', 'w-5 h-5')"></span>
<span x-show="searching" class="spinner-sm"></span>
<span class="hidden sm:inline">Search</span>
</button>
</form>
</div>
{# Results Area #}
<div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# No Query Yet #}
<div x-show="!loading && !query" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">
<span x-html="$icon('search', 'w-16 h-16 mx-auto text-gray-400')"></span>
</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Start Your Search
</h3>
<p class="text-gray-600 dark:text-gray-400">
Enter a search term above to find products
</p>
</div>
{# Search Results Grid #}
<div x-show="!loading && query && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
{# Brand badge if available #}
<div x-show="product.brand" class="mb-2">
<span class="inline-block px-2 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded" x-text="product.brand"></span>
</div>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(product.price)"></span>
<span x-show="product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(product.sale_price)"></span>
</div>
<button @click.prevent="addToCart(product)"
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2"
style="background-color: var(--color-primary)"
:title="'Add to Cart'">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="hidden sm:inline">Add</span>
</button>
</div>
</div>
</div>
</template>
</div>
{# No Results Message #}
<div x-show="!loading && query && products.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">
<span x-html="$icon('search-x', 'w-16 h-16 mx-auto text-gray-400')"></span>
</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
No Results Found
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
No products match "<span x-text="query" class="font-medium"></span>"
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
Try different keywords or check the spelling
</p>
</div>
{# Pagination #}
<div x-show="!loading && query && totalPages > 1" class="mt-8 flex justify-center">
<div class="flex gap-2">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<template x-for="page in visiblePages" :key="page">
<button
@click="goToPage(page)"
:class="page === currentPage ? 'bg-primary text-white' : 'border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'"
class="px-4 py-2 rounded-lg"
:style="page === currentPage ? 'background-color: var(--color-primary)' : ''"
x-text="page"
></button>
</template>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shopSearch', () => ({
...shopLayoutData(),
// Search state
searchInput: '',
query: '',
products: [],
total: 0,
loading: false,
searching: false,
// Pagination
currentPage: 1,
perPage: 12,
get totalPages() {
return Math.ceil(this.total / this.perPage);
},
get visiblePages() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
let start = Math.max(1, current - 2);
let end = Math.min(total, current + 2);
// Adjust to always show 5 pages if possible
if (end - start < 4) {
if (start === 1) {
end = Math.min(total, 5);
} else {
start = Math.max(1, total - 4);
}
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
async init() {
console.log('[SHOP] Search page initializing...');
// Check for query parameter in URL
const urlParams = new URLSearchParams(window.location.search);
const urlQuery = urlParams.get('q');
if (urlQuery) {
this.searchInput = urlQuery;
this.query = urlQuery;
await this.loadResults();
}
},
async performSearch() {
if (!this.searchInput.trim()) {
return;
}
this.query = this.searchInput.trim();
this.currentPage = 1;
// Update URL without reload
const url = new URL(window.location);
url.searchParams.set('q', this.query);
window.history.pushState({}, '', url);
await this.loadResults();
},
async loadResults() {
if (!this.query) return;
this.loading = true;
this.searching = true;
try {
const params = new URLSearchParams({
q: this.query,
skip: (this.currentPage - 1) * this.perPage,
limit: this.perPage
});
console.log(`[SHOP] Searching: /api/v1/shop/products/search?${params}`);
const response = await fetch(`/api/v1/shop/products/search?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`[SHOP] Search found ${data.total} results`);
this.products = data.products;
this.total = data.total;
} catch (error) {
console.error('[SHOP] Search failed:', error);
this.showToast('Search failed. Please try again.', 'error');
this.products = [];
this.total = 0;
} finally {
this.loading = false;
this.searching = false;
}
},
async goToPage(page) {
if (page < 1 || page > this.totalPages) return;
this.currentPage = page;
await this.loadResults();
// Scroll to top of results
window.scrollTo({ top: 0, behavior: 'smooth' });
},
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}
}));
});
</script>
{% endblock %}

View File

@@ -0,0 +1,252 @@
{# app/modules/catalog/templates/catalog/storefront/wishlist.html #}
{% extends "storefront/base.html" %}
{% block title %}My Wishlist{% endblock %}
{# Alpine.js component #}
{% block alpine_data %}shopWishlist(){% endblock %}
{% block content %}
<div class="max-w-7xl 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>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">Account</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Wishlist</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-2">
My Wishlist
</h1>
<p class="text-gray-600 dark:text-gray-400" x-show="!loading && items.length > 0">
<span x-text="items.length" class="font-semibold"></span> saved item<span x-show="items.length !== 1">s</span>
</p>
</div>
{# Wishlist Content #}
<div>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="spinner"></div>
</div>
{# Not Logged In Message #}
<div x-show="!loading && !isLoggedIn" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">
<span x-html="$icon('user', 'w-16 h-16 mx-auto text-gray-400')"></span>
</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Please Log In
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Log in to your account to view and manage your wishlist.
</p>
<a href="{{ base_url }}shop/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Log In
</a>
</div>
{# Wishlist Items Grid #}
<div x-show="!loading && isLoggedIn && items.length > 0" class="product-grid">
<template x-for="item in items" :key="item.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden relative">
{# Remove from Wishlist Button #}
<button
@click="removeFromWishlist(item)"
class="absolute top-2 right-2 z-10 p-2 bg-white dark:bg-gray-700 rounded-full shadow-md hover:bg-red-50 dark:hover:bg-red-900 transition-colors group"
title="Remove from wishlist"
>
<span x-html="$icon('heart', 'w-5 h-5 text-red-500 fill-current')"></span>
</button>
<a :href="`{{ base_url }}shop/products/${item.product.id}`">
<img :src="item.product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:alt="item.product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${item.product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="item.product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="item.product.marketplace_product?.description"></p>
{# Availability #}
<div class="mb-3">
<span
x-show="item.product.available_inventory > 0"
class="text-sm text-green-600 dark:text-green-400"
>
In Stock
</span>
<span
x-show="!item.product.available_inventory || item.product.available_inventory <= 0"
class="text-sm text-red-600 dark:text-red-400"
>
Out of Stock
</span>
</div>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<span class="text-xl sm:text-2xl font-bold text-primary" x-text="formatPrice(item.product.price)"></span>
<span x-show="item.product.sale_price" class="text-sm text-gray-500 line-through ml-2" x-text="formatPrice(item.product.sale_price)"></span>
</div>
<button @click.prevent="addToCart(item.product)"
:disabled="!item.product.available_inventory || item.product.available_inventory <= 0"
class="flex-shrink-0 p-2 sm:px-4 sm:py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
style="background-color: var(--color-primary)"
:title="'Add to Cart'">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span class="hidden sm:inline">Add to Cart</span>
</button>
</div>
</div>
</div>
</template>
</div>
{# Empty Wishlist Message #}
<div x-show="!loading && isLoggedIn && items.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-6xl mb-4">
<span x-html="$icon('heart', 'w-16 h-16 mx-auto text-gray-400')"></span>
</div>
<h3 class="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
Your Wishlist is Empty
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Save items you like by clicking the heart icon on product pages.
</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('shopWishlist', () => ({
...shopLayoutData(),
// Data
items: [],
loading: true,
isLoggedIn: false,
async init() {
console.log('[SHOP] Wishlist page initializing...');
// Check if user is logged in
this.isLoggedIn = await this.checkLoginStatus();
if (this.isLoggedIn) {
await this.loadWishlist();
} else {
this.loading = false;
}
},
async checkLoginStatus() {
try {
const response = await fetch('/api/v1/shop/customers/me');
return response.ok;
} catch (error) {
return false;
}
},
async loadWishlist() {
this.loading = true;
try {
console.log('[SHOP] Loading wishlist...');
const response = await fetch('/api/v1/shop/wishlist');
if (!response.ok) {
if (response.status === 401) {
this.isLoggedIn = false;
return;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
this.items = data.items || [];
} catch (error) {
console.error('[SHOP] Failed to load wishlist:', error);
this.showToast('Failed to load wishlist', 'error');
} finally {
this.loading = false;
}
},
async removeFromWishlist(item) {
try {
console.log('[SHOP] Removing from wishlist:', item);
const response = await fetch(`/api/v1/shop/wishlist/${item.id}`, {
method: 'DELETE'
});
if (response.ok) {
this.items = this.items.filter(i => i.id !== item.id);
this.showToast('Removed from wishlist', 'success');
} else {
throw new Error('Failed to remove from wishlist');
}
} catch (error) {
console.error('[SHOP] Failed to remove from wishlist:', error);
this.showToast('Failed to remove from wishlist', 'error');
}
},
async addToCart(product) {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
console.log('[SHOP] Add to cart success:', result);
this.cartCount += 1;
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
} else {
const error = await response.json();
console.error('[SHOP] Add to cart error:', error);
this.showToast(error.message || 'Failed to add to cart', 'error');
}
} catch (error) {
console.error('[SHOP] Add to cart exception:', error);
this.showToast('Failed to add to cart', 'error');
}
}
}));
});
</script>
{% endblock %}

View File

@@ -0,0 +1,174 @@
{# app/templates/vendor/product-create.html #}
{% extends "vendor/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% block title %}Create Product{% endblock %}
{% block alpine_data %}vendorProductCreate(){% endblock %}
{% block content %}
{% call detail_page_header("'Create Product'", backUrl) %}
<span>Add a new product to your catalog</span>
{% endcall %}
<!-- Create Form -->
<form @submit.prevent="createProduct()">
<!-- Basic Information -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Basic Information
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Title *</label>
<input
type="text"
x-model="form.title"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Brand</label>
<input
type="text"
x-model="form.brand"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Brand name"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">SKU</label>
<input
type="text"
x-model="form.vendor_sku"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="SKU"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN/EAN</label>
<input
type="text"
x-model="form.gtin"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="GTIN/EAN"
/>
</div>
</div>
</div>
<!-- Pricing -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing
</h3>
<div class="grid gap-4 md:grid-cols-3">
{# noqa: FE-008 - Using raw number input for price field #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price *</label>
<input
type="number"
step="0.01"
x-model="form.price"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Currency</label>
<select
x-model="form.currency"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label>
<select
x-model="form.availability"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="in_stock">In Stock</option>
<option value="out_of_stock">Out of Stock</option>
<option value="preorder">Preorder</option>
<option value="backorder">Backorder</option>
</select>
</div>
</div>
</div>
<!-- Status -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Status
</h3>
<div class="flex flex-wrap gap-6">
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_active"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_featured"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_digital"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
</label>
</div>
</div>
<!-- Description -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Description
</h3>
<textarea
x-model="form.description"
rows="6"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product description"
></textarea>
</div>
<!-- Actions -->
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
<a
:href="backUrl"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</a>
<button
type="submit"
:disabled="saving || !form.title"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="saving ? 'Creating...' : 'Create Product'"></span>
</button>
</div>
</form>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('catalog_static', path='vendor/js/product-create.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,368 @@
{# app/templates/vendor/products.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/tables.html' import table_wrapper %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Products{% endblock %}
{% block alpine_data %}vendorProducts(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Products', subtitle='Manage your product catalog') %}
<div class="flex items-center gap-4">
{{ refresh_button(loading_var='loading', onclick='loadProducts()', variant='secondary') }}
<button
@click="createProduct()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Add Product
</button>
</div>
{% endcall %}
{{ loading_state('Loading products...') }}
{{ error_state('Error loading products') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Total Products -->
<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('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Products</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">0</p>
</div>
</div>
<!-- Active Products -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active">0</p>
</div>
</div>
<!-- Inactive Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Inactive</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.inactive">0</p>
</div>
</div>
<!-- Featured Products -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('star', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Featured</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.featured">0</p>
</div>
</div>
</div>
<!-- Filters -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<!-- Search -->
<div class="flex-1 min-w-[200px]">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search products..."
class="w-full pl-10 pr-4 py-2 text-sm text-gray-700 placeholder-gray-400 bg-gray-50 border border-gray-200 rounded-lg dark:placeholder-gray-500 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
/>
</div>
</div>
<!-- Status Filter -->
<select
x-model="filters.status"
@change="applyFilter()"
class="px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<!-- Featured Filter -->
<select
x-model="filters.featured"
@change="applyFilter()"
class="px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
>
<option value="">All Products</option>
<option value="true">Featured Only</option>
<option value="false">Not Featured</option>
</select>
<!-- Clear Filters -->
<button
x-show="filters.search || filters.status || filters.featured"
@click="clearFilters()"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear filters
</button>
</div>
</div>
<!-- Bulk Actions Bar -->
<div x-show="!loading && selectedProducts.length > 0"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
class="mb-4 p-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
<span x-text="selectedProducts.length"></span> product(s) selected
</span>
<button @click="clearSelection()" class="text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
Clear
</button>
</div>
<div class="flex items-center gap-2">
<button
@click="bulkActivate()"
:disabled="saving"
class="px-3 py-1.5 text-sm font-medium text-green-700 bg-green-100 rounded-lg hover:bg-green-200 dark:bg-green-900 dark:text-green-300 dark:hover:bg-green-800 disabled:opacity-50"
>
<span x-html="$icon('check-circle', 'w-4 h-4 inline mr-1')"></span>
Activate
</button>
<button
@click="bulkDeactivate()"
:disabled="saving"
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
>
<span x-html="$icon('x-circle', 'w-4 h-4 inline mr-1')"></span>
Deactivate
</button>
<button
@click="bulkSetFeatured()"
:disabled="saving"
class="px-3 py-1.5 text-sm font-medium text-yellow-700 bg-yellow-100 rounded-lg hover:bg-yellow-200 dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 disabled:opacity-50"
>
<span x-html="$icon('star', 'w-4 h-4 inline mr-1')"></span>
Feature
</button>
<button
@click="bulkRemoveFeatured()"
:disabled="saving"
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
>
Unfeature
</button>
<button
@click="confirmBulkDelete()"
:disabled="saving"
class="px-3 py-1.5 text-sm font-medium text-red-700 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900 dark:text-red-300 dark:hover:bg-red-800 disabled:opacity-50"
>
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
Delete
</button>
</div>
</div>
<!-- Products Table -->
<div x-show="!loading && !error" class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3 w-10">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
@click="toggleSelectAll()"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</th>
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">SKU</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Featured</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="product in products" :key="product.id">
<tr class="text-gray-700 dark:text-gray-400" :class="{'bg-purple-50 dark:bg-purple-900/10': isSelected(product.id)}">
<!-- Checkbox -->
<td class="px-4 py-3">
<input
type="checkbox"
:checked="isSelected(product.id)"
@click="toggleSelect(product.id)"
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600"
/>
</td>
<!-- Product Info -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative w-10 h-10 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700">
<img
x-show="product.image_url"
:src="product.image_url"
:alt="product.name"
class="object-cover w-full h-full"
/>
<div
x-show="!product.image_url"
class="flex items-center justify-center w-full h-full text-gray-400"
>
<span x-html="$icon('photo', 'w-5 h-5')"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="product.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.category || 'No category'"></p>
</div>
</div>
</td>
<!-- SKU -->
<td class="px-4 py-3 text-sm font-mono" x-text="product.sku || '-'"></td>
<!-- Price -->
<td class="px-4 py-3 text-sm font-semibold" x-text="formatPrice(product.price)"></td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<button
@click="toggleActive(product)"
:class="product.is_active
? 'px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
: 'px-2 py-1 font-semibold leading-tight text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
x-text="product.is_active ? 'Active' : 'Inactive'"
></button>
</td>
<!-- Featured -->
<td class="px-4 py-3">
<button
@click="toggleFeatured(product)"
:class="product.is_featured ? 'text-yellow-500' : 'text-gray-300 hover:text-yellow-500'"
>
<span x-html="$icon('star', 'w-5 h-5')"></span>
</button>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewProduct(product)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
title="View"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="editProduct(product)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Edit"
>
<span x-html="$icon('pencil', 'w-5 h-5')"></span>
</button>
<button
@click="confirmDelete(product)"
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
title="Delete"
>
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
<!-- Empty State -->
<tr x-show="products.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('cube', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No products found</p>
<p class="text-sm">Add your first product to get started</p>
<button
@click="createProduct()"
class="mt-4 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
Add Product
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{{ pagination(show_condition="!loading && pagination.total > 0") }}
<!-- Delete Confirmation Modal -->
{% call modal_simple('deleteProductModal', 'Delete Product', show_var='showDeleteModal', size='sm') %}
<div class="space-y-4">
<template x-if="selectedProduct">
<p class="text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to delete <span class="font-semibold" x-text="selectedProduct.name"></span>?
This action cannot be undone.
</p>
</template>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showDeleteModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>Cancel</button>
<button
@click="deleteProduct()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>Delete</button>
</div>
</div>
{% endcall %}
<!-- Bulk Delete Confirmation Modal -->
{% call modal_simple('bulkDeleteProductModal', 'Delete Selected Products', show_var='showBulkDeleteModal', size='sm') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to delete <span class="font-semibold" x-text="selectedProducts.length"></span> selected product(s)?
This action cannot be undone.
</p>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showBulkDeleteModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>Cancel</button>
<button
@click="bulkDelete()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>Delete All</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('catalog_static', path='vendor/js/products.js') }}"></script>
{% endblock %}

View File

@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from app.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.cart.services import cart_service
from app.modules.checkout.schemas import (
CheckoutRequest,
@@ -28,7 +28,7 @@ from app.modules.checkout.schemas import (
from app.modules.checkout.services import checkout_service
from app.modules.customers.schemas import CustomerContext
from app.modules.orders.services import order_service
from app.services.email_service import EmailService # noqa: MOD-004 - Core email service
from app.modules.messaging.services.email_service import EmailService # noqa: MOD-004 - Core email service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from app.modules.orders.schemas import OrderCreate, OrderResponse

View File

@@ -0,0 +1,2 @@
# app/modules/checkout/routes/pages/__init__.py
"""Checkout module page routes."""

View File

@@ -0,0 +1,46 @@
# app/modules/checkout/routes/pages/storefront.py
"""
Checkout Storefront Page Routes (HTML rendering).
Storefront (customer shop) pages for checkout:
- Checkout page
"""
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.modules.core.utils.page_context import get_storefront_context
from app.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# CHECKOUT
# ============================================================================
@router.get("/checkout", response_class=HTMLResponse, include_in_schema=False)
async def shop_checkout_page(request: Request, db: Session = Depends(get_db)):
"""
Render checkout page.
Handles shipping, payment, and order confirmation.
"""
logger.debug(
"[STOREFRONT] shop_checkout_page REACHED",
extra={
"path": request.url.path,
"vendor": getattr(request.state, "vendor", "NOT SET"),
"context": getattr(request.state, "context_type", "NOT SET"),
},
)
return templates.TemplateResponse(
"checkout/storefront/checkout.html", get_storefront_context(request, db=db)
)

View File

@@ -0,0 +1,915 @@
{# app/templates/storefront/checkout.html #}
{% extends "storefront/base.html" %}
{% block title %}Checkout - {{ vendor.name }}{% endblock %}
{% block alpine_data %}checkoutPage(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{# Breadcrumbs #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li><a href="{{ base_url }}shop/" class="hover:text-primary">Home</a></li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<a href="{{ base_url }}shop/cart" class="hover:text-primary">Cart</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<span class="text-gray-900 dark:text-white">Checkout</span>
</li>
</ol>
</nav>
{# Page Header #}
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
{# Loading State #}
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
</div>
{# Empty Cart #}
<div x-show="!loading && cartItems.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<span class="mx-auto h-12 w-12 text-gray-400 mb-4 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">Your cart is empty</h3>
<p class="text-gray-500 dark:text-gray-400 mb-6">Add some products before checking out.</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
{# Checkout Form #}
<div x-show="!loading && cartItems.length > 0" x-cloak>
<form @submit.prevent="placeOrder()" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# Left Column - Forms #}
<div class="lg:col-span-2 space-y-6">
{# Step Indicator #}
<div class="flex items-center justify-center mb-8">
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" style="background-color: var(--color-primary)">1</div>
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Information</span>
</div>
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 2 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 2 ? 'background-color: var(--color-primary)' : ''">2</div>
<span class="ml-2 text-sm font-medium" :class="step >= 2 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Shipping</span>
</div>
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 3 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 3 ? 'background-color: var(--color-primary)' : ''">3</div>
<span class="ml-2 text-sm font-medium" :class="step >= 3 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Review</span>
</div>
</div>
{# Error Message #}
<div x-show="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex">
<span class="h-5 w-5 text-red-400" x-html="$icon('x-circle', 'h-5 w-5')"></span>
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
</div>
</div>
{# Step 1: Contact & Shipping Address #}
<div x-show="step === 1" class="space-y-6">
{# Contact Information #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Contact Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="customer.first_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="customer.last_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email *</label>
<input type="email" x-model="customer.email" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
<input type="tel" x-model="customer.phone"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
</div>
</div>
{# Shipping Address #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Shipping Address</h2>
{# Saved Addresses Selector (only shown for logged in customers) #}
<div x-show="isLoggedIn && shippingAddresses.length > 0" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
<select x-model="selectedShippingAddressId" @change="populateFromSavedAddress('shipping')"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Enter a new address</option>
<template x-for="addr in shippingAddresses" :key="addr.id">
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
</template>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="shippingAddress.first_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="shippingAddress.last_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
<input type="text" x-model="shippingAddress.company"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="shippingAddress.address_line_1" required placeholder="Street and number"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input type="text" x-model="shippingAddress.address_line_2" placeholder="Apartment, suite, etc."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<input type="text" x-model="shippingAddress.postal_code" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<input type="text" x-model="shippingAddress.city" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="shippingAddress.country_iso" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Select a country</option>
<template x-for="country in countries" :key="country.code">
<option :value="country.code" x-text="country.name"></option>
</template>
</select>
</div>
{# Save Address Checkbox (only for new addresses when logged in) #}
<div x-show="isLoggedIn && !selectedShippingAddressId" class="md:col-span-2">
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="saveShippingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
</label>
</div>
</div>
</div>
{# Billing Address #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="sameAsShipping" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Same as shipping</span>
</label>
</div>
{# Saved Addresses Selector (only shown for logged in customers when not same as shipping) #}
<div x-show="isLoggedIn && !sameAsShipping && billingAddresses.length > 0" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
<select x-model="selectedBillingAddressId" @change="populateFromSavedAddress('billing')"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Enter a new address</option>
<template x-for="addr in billingAddresses" :key="addr.id">
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
</template>
</select>
</div>
<div x-show="!sameAsShipping" x-collapse class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="billingAddress.first_name" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="billingAddress.last_name" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
<input type="text" x-model="billingAddress.company"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="billingAddress.address_line_1" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input type="text" x-model="billingAddress.address_line_2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<input type="text" x-model="billingAddress.postal_code" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<input type="text" x-model="billingAddress.city" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="billingAddress.country_iso" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Select a country</option>
<template x-for="country in countries" :key="country.code">
<option :value="country.code" x-text="country.name"></option>
</template>
</select>
</div>
{# Save Address Checkbox (only for new addresses when logged in) #}
<div x-show="isLoggedIn && !selectedBillingAddressId" class="md:col-span-2">
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="saveBillingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
</label>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="button" @click="goToStep(2)"
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
style="background-color: var(--color-primary)">
Continue to Shipping
</button>
</div>
</div>
{# Step 2: Shipping Method #}
<div x-show="step === 2" class="space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Shipping Method</h2>
<div class="space-y-3">
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="shippingMethod === 'standard' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
:style="shippingMethod === 'standard' ? 'border-color: var(--color-primary)' : ''">
<input type="radio" name="shipping" value="standard" x-model="shippingMethod" class="hidden">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">Standard Shipping</p>
<p class="text-sm text-gray-500 dark:text-gray-400">3-5 business days</p>
</div>
<span class="font-semibold text-gray-900 dark:text-white" x-text="subtotal >= 50 ? 'FREE' : '5.99'"></span>
</label>
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="shippingMethod === 'express' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
:style="shippingMethod === 'express' ? 'border-color: var(--color-primary)' : ''">
<input type="radio" name="shipping" value="express" x-model="shippingMethod" class="hidden">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">Express Shipping</p>
<p class="text-sm text-gray-500 dark:text-gray-400">1-2 business days</p>
</div>
<span class="font-semibold text-gray-900 dark:text-white">9.99</span>
</label>
</div>
</div>
{# Order Notes #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Notes (Optional)</h2>
<textarea x-model="customerNotes" rows="3" placeholder="Special instructions for your order..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
</div>
<div class="flex justify-between">
<button type="button" @click="goToStep(1)"
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Back
</button>
<button type="button" @click="goToStep(3)"
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
style="background-color: var(--color-primary)">
Review Order
</button>
</div>
</div>
{# Step 3: Review & Place Order #}
<div x-show="step === 3" class="space-y-6">
{# Review Contact Info #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Contact Information</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<p class="text-gray-700 dark:text-gray-300" x-text="customer.first_name + ' ' + customer.last_name"></p>
<p class="text-gray-600 dark:text-gray-400" x-text="customer.email"></p>
<p x-show="customer.phone" class="text-gray-600 dark:text-gray-400" x-text="customer.phone"></p>
</div>
{# Review Addresses #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Address</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
<p class="font-medium text-gray-900 dark:text-white" x-text="shippingAddress.first_name + ' ' + shippingAddress.last_name"></p>
<p x-show="shippingAddress.company" x-text="shippingAddress.company"></p>
<p x-text="shippingAddress.address_line_1"></p>
<p x-show="shippingAddress.address_line_2" x-text="shippingAddress.address_line_2"></p>
<p x-text="shippingAddress.postal_code + ' ' + shippingAddress.city"></p>
<p x-text="getCountryName(shippingAddress.country_iso)"></p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
<template x-if="sameAsShipping">
<p class="italic">Same as shipping address</p>
</template>
<template x-if="!sameAsShipping">
<div>
<p class="font-medium text-gray-900 dark:text-white" x-text="billingAddress.first_name + ' ' + billingAddress.last_name"></p>
<p x-show="billingAddress.company" x-text="billingAddress.company"></p>
<p x-text="billingAddress.address_line_1"></p>
<p x-show="billingAddress.address_line_2" x-text="billingAddress.address_line_2"></p>
<p x-text="billingAddress.postal_code + ' ' + billingAddress.city"></p>
<p x-text="getCountryName(billingAddress.country_iso)"></p>
</div>
</template>
</div>
</div>
</div>
{# Review Shipping #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Method</h2>
<button type="button" @click="goToStep(2)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<p class="text-gray-700 dark:text-gray-300" x-text="shippingMethod === 'express' ? 'Express Shipping (1-2 business days)' : 'Standard Shipping (3-5 business days)'"></p>
</div>
{# Order Items Review #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Items</h2>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="item in cartItems" :key="item.product_id">
<div class="py-4 flex items-center gap-4">
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
class="w-16 h-16 object-cover rounded-lg">
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Qty: <span x-text="item.quantity"></span></p>
</div>
<p class="font-semibold text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
</div>
</template>
</div>
</div>
<div class="flex justify-between">
<button type="button" @click="goToStep(2)"
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Back
</button>
<button type="submit" :disabled="submitting"
class="px-8 py-3 text-white rounded-lg font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style="background-color: var(--color-primary)">
<span x-show="!submitting">Place Order</span>
<span x-show="submitting" class="flex items-center">
<span class="-ml-1 mr-2 h-5 w-5" x-html="$icon('spinner', 'h-5 w-5 animate-spin')"></span>
Processing...
</span>
</button>
</div>
</div>
</div>
{# Right Column - Order Summary #}
<div class="lg:col-span-1">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 sticky top-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
Order Summary
</h3>
{# Cart Items Preview #}
<div class="space-y-3 mb-6 max-h-64 overflow-y-auto">
<template x-for="item in cartItems" :key="item.product_id">
<div class="flex items-center gap-3">
<div class="relative">
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
class="w-12 h-12 object-cover rounded">
<span class="absolute -top-2 -right-2 w-5 h-5 bg-gray-500 text-white text-xs rounded-full flex items-center justify-center" x-text="item.quantity"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
</div>
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
</div>
</template>
</div>
{# Totals #}
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + subtotal.toFixed(2)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="shippingCost === 0 ? 'FREE' : '€' + shippingCost.toFixed(2)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Tax (incl.)</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + tax.toFixed(2)"></span>
</div>
<div class="flex justify-between text-lg font-bold pt-3 border-t border-gray-200 dark:border-gray-700">
<span class="text-gray-900 dark:text-white">Total</span>
<span style="color: var(--color-primary)" x-text="'€' + total.toFixed(2)"></span>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
Free shipping on orders over €50
</p>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function checkoutPage() {
return {
...shopLayoutData(),
// State
loading: true,
submitting: false,
error: '',
step: 1,
// Cart data
cartItems: [],
// Customer info
customer: {
first_name: '',
last_name: '',
email: '',
phone: ''
},
// Saved addresses (for logged in customers)
isLoggedIn: false,
savedAddresses: [],
selectedShippingAddressId: '',
selectedBillingAddressId: '',
saveShippingAddress: false,
saveBillingAddress: false,
// Computed filtered addresses by type
get shippingAddresses() {
return this.savedAddresses.filter(a => a.address_type === 'shipping');
},
get billingAddresses() {
return this.savedAddresses.filter(a => a.address_type === 'billing');
},
// Shipping address
shippingAddress: {
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_iso: 'LU'
},
// Billing address
billingAddress: {
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_iso: 'LU'
},
sameAsShipping: true,
shippingMethod: 'standard',
customerNotes: '',
// Countries list
countries: [
{ code: 'LU', name: 'Luxembourg' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'BE', name: 'Belgium' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'AT', name: 'Austria' },
{ code: 'IT', name: 'Italy' },
{ code: 'ES', name: 'Spain' },
{ code: 'PT', name: 'Portugal' },
{ code: 'PL', name: 'Poland' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'SK', name: 'Slovakia' },
{ code: 'HU', name: 'Hungary' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'HR', name: 'Croatia' },
{ code: 'RO', name: 'Romania' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'GR', name: 'Greece' },
{ code: 'IE', name: 'Ireland' },
{ code: 'DK', name: 'Denmark' },
{ code: 'SE', name: 'Sweden' },
{ code: 'FI', name: 'Finland' },
{ code: 'EE', name: 'Estonia' },
{ code: 'LV', name: 'Latvia' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'MT', name: 'Malta' },
{ code: 'CY', name: 'Cyprus' }
],
// Computed
get subtotal() {
return this.cartItems.reduce((sum, item) => sum + (parseFloat(item.price) * item.quantity), 0);
},
get shippingCost() {
if (this.shippingMethod === 'express') return 9.99;
return this.subtotal >= 50 ? 0 : 5.99;
},
get tax() {
// VAT is included in price, calculate the VAT portion (17% for LU)
const vatRate = 0.17;
return this.subtotal * vatRate / (1 + vatRate);
},
get total() {
return this.subtotal + this.shippingCost;
},
async init() {
console.log('[CHECKOUT] Initializing...');
// Initialize session
if (typeof shopLayoutData === 'function') {
const baseData = shopLayoutData();
if (baseData.init) {
baseData.init.call(this);
}
}
// Check if customer is logged in and pre-fill data
await this.loadCustomerData();
// Load cart
await this.loadCart();
},
async loadCustomerData() {
try {
const response = await fetch('/api/v1/shop/auth/me');
if (response.ok) {
const data = await response.json();
this.isLoggedIn = true;
// Pre-fill customer info
this.customer.first_name = data.first_name || '';
this.customer.last_name = data.last_name || '';
this.customer.email = data.email || '';
this.customer.phone = data.phone || '';
// Pre-fill shipping address with customer name
this.shippingAddress.first_name = data.first_name || '';
this.shippingAddress.last_name = data.last_name || '';
console.log('[CHECKOUT] Customer data loaded');
// Load saved addresses for logged in customer
await this.loadSavedAddresses();
}
} catch (error) {
console.log('[CHECKOUT] No customer logged in or error:', error);
this.isLoggedIn = false;
}
},
async loadSavedAddresses() {
try {
const response = await fetch('/api/v1/shop/addresses');
if (response.ok) {
const data = await response.json();
this.savedAddresses = data.addresses || [];
console.log('[CHECKOUT] Saved addresses loaded:', this.savedAddresses.length);
// Auto-select default shipping address
const defaultShipping = this.shippingAddresses.find(a => a.is_default);
if (defaultShipping) {
this.selectedShippingAddressId = defaultShipping.id;
this.populateFromSavedAddress('shipping');
}
// Auto-select default billing address
const defaultBilling = this.billingAddresses.find(a => a.is_default);
if (defaultBilling) {
this.selectedBillingAddressId = defaultBilling.id;
this.populateFromSavedAddress('billing');
}
}
} catch (error) {
console.error('[CHECKOUT] Failed to load saved addresses:', error);
}
},
populateFromSavedAddress(type) {
const addressId = type === 'shipping' ? this.selectedShippingAddressId : this.selectedBillingAddressId;
const addresses = type === 'shipping' ? this.shippingAddresses : this.billingAddresses;
const targetAddress = type === 'shipping' ? this.shippingAddress : this.billingAddress;
if (!addressId) {
// Clear form when "Enter a new address" is selected
targetAddress.first_name = type === 'shipping' ? this.customer.first_name : '';
targetAddress.last_name = type === 'shipping' ? this.customer.last_name : '';
targetAddress.company = '';
targetAddress.address_line_1 = '';
targetAddress.address_line_2 = '';
targetAddress.city = '';
targetAddress.postal_code = '';
targetAddress.country_iso = 'LU';
return;
}
const savedAddr = addresses.find(a => a.id == addressId);
if (savedAddr) {
targetAddress.first_name = savedAddr.first_name || '';
targetAddress.last_name = savedAddr.last_name || '';
targetAddress.company = savedAddr.company || '';
targetAddress.address_line_1 = savedAddr.address_line_1 || '';
targetAddress.address_line_2 = savedAddr.address_line_2 || '';
targetAddress.city = savedAddr.city || '';
targetAddress.postal_code = savedAddr.postal_code || '';
targetAddress.country_iso = savedAddr.country_iso || 'LU';
console.log(`[CHECKOUT] Populated ${type} address from saved:`, savedAddr.id);
}
},
formatAddressOption(addr) {
const name = `${addr.first_name} ${addr.last_name}`.trim();
const location = `${addr.address_line_1}, ${addr.postal_code} ${addr.city}`;
const defaultBadge = addr.is_default ? ' (Default)' : '';
return `${name} - ${location}${defaultBadge}`;
},
async loadCart() {
this.loading = true;
try {
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
this.cartItems = data.items || [];
console.log('[CHECKOUT] Cart loaded:', this.cartItems.length, 'items');
}
} catch (error) {
console.error('[CHECKOUT] Failed to load cart:', error);
this.error = 'Failed to load cart';
} finally {
this.loading = false;
}
},
goToStep(newStep) {
// Validate current step before moving forward
if (newStep > this.step) {
if (this.step === 1 && !this.validateStep1()) {
return;
}
}
this.step = newStep;
window.scrollTo({ top: 0, behavior: 'smooth' });
},
validateStep1() {
// Validate customer info
if (!this.customer.first_name || !this.customer.last_name || !this.customer.email) {
this.error = 'Please fill in all required contact fields';
return false;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.customer.email)) {
this.error = 'Please enter a valid email address';
return false;
}
// Validate shipping address
if (!this.shippingAddress.first_name || !this.shippingAddress.last_name ||
!this.shippingAddress.address_line_1 || !this.shippingAddress.city ||
!this.shippingAddress.postal_code || !this.shippingAddress.country_iso) {
this.error = 'Please fill in all required shipping address fields';
return false;
}
// Validate billing address if not same as shipping
if (!this.sameAsShipping) {
if (!this.billingAddress.first_name || !this.billingAddress.last_name ||
!this.billingAddress.address_line_1 || !this.billingAddress.city ||
!this.billingAddress.postal_code || !this.billingAddress.country_iso) {
this.error = 'Please fill in all required billing address fields';
return false;
}
}
this.error = '';
return true;
},
getCountryName(code) {
const country = this.countries.find(c => c.code === code);
return country ? country.name : code;
},
async saveNewAddresses() {
// Save shipping address if checkbox is checked and it's a new address
if (this.saveShippingAddress && !this.selectedShippingAddressId) {
try {
const country = this.countries.find(c => c.code === this.shippingAddress.country_iso);
const addressData = {
address_type: 'shipping',
first_name: this.shippingAddress.first_name,
last_name: this.shippingAddress.last_name,
company: this.shippingAddress.company || null,
address_line_1: this.shippingAddress.address_line_1,
address_line_2: this.shippingAddress.address_line_2 || null,
city: this.shippingAddress.city,
postal_code: this.shippingAddress.postal_code,
country_name: country ? country.name : this.shippingAddress.country_iso,
country_iso: this.shippingAddress.country_iso,
is_default: this.shippingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
});
if (response.ok) {
console.log('[CHECKOUT] Shipping address saved');
}
} catch (error) {
console.error('[CHECKOUT] Failed to save shipping address:', error);
}
}
// Save billing address if checkbox is checked, it's a new address, and not same as shipping
if (this.saveBillingAddress && !this.selectedBillingAddressId && !this.sameAsShipping) {
try {
const country = this.countries.find(c => c.code === this.billingAddress.country_iso);
const addressData = {
address_type: 'billing',
first_name: this.billingAddress.first_name,
last_name: this.billingAddress.last_name,
company: this.billingAddress.company || null,
address_line_1: this.billingAddress.address_line_1,
address_line_2: this.billingAddress.address_line_2 || null,
city: this.billingAddress.city,
postal_code: this.billingAddress.postal_code,
country_name: country ? country.name : this.billingAddress.country_iso,
country_iso: this.billingAddress.country_iso,
is_default: this.billingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
});
if (response.ok) {
console.log('[CHECKOUT] Billing address saved');
}
} catch (error) {
console.error('[CHECKOUT] Failed to save billing address:', error);
}
}
},
async placeOrder() {
this.error = '';
this.submitting = true;
try {
// Save new addresses if requested (only for logged in users with new addresses)
if (this.isLoggedIn) {
await this.saveNewAddresses();
}
// Build order data
const orderData = {
items: this.cartItems.map(item => ({
product_id: item.product_id,
quantity: item.quantity
})),
customer: {
first_name: this.customer.first_name,
last_name: this.customer.last_name,
email: this.customer.email,
phone: this.customer.phone || null
},
shipping_address: {
first_name: this.shippingAddress.first_name,
last_name: this.shippingAddress.last_name,
company: this.shippingAddress.company || null,
address_line_1: this.shippingAddress.address_line_1,
address_line_2: this.shippingAddress.address_line_2 || null,
city: this.shippingAddress.city,
postal_code: this.shippingAddress.postal_code,
country_iso: this.shippingAddress.country_iso
},
billing_address: this.sameAsShipping ? null : {
first_name: this.billingAddress.first_name,
last_name: this.billingAddress.last_name,
company: this.billingAddress.company || null,
address_line_1: this.billingAddress.address_line_1,
address_line_2: this.billingAddress.address_line_2 || null,
city: this.billingAddress.city,
postal_code: this.billingAddress.postal_code,
country_iso: this.billingAddress.country_iso
},
shipping_method: this.shippingMethod,
customer_notes: this.customerNotes || null,
session_id: this.sessionId
};
console.log('[CHECKOUT] Placing order:', orderData);
const response = await fetch('/api/v1/shop/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(orderData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to place order');
}
const order = await response.json();
console.log('[CHECKOUT] Order placed:', order.order_number);
// Redirect to confirmation page
window.location.href = '{{ base_url }}shop/order-confirmation?order=' + order.order_number;
} catch (error) {
console.error('[CHECKOUT] Error placing order:', error);
this.error = error.message || 'Failed to place order. Please try again.';
} finally {
this.submitting = false;
}
}
};
}
</script>
{% endblock %}

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 %}

View File

@@ -2,11 +2,16 @@
"""
Core module API routes.
Admin routes:
- /dashboard/* - Admin dashboard and statistics
- /settings/* - Platform settings management
Vendor routes:
- /dashboard/* - Dashboard statistics
- /settings/* - Vendor settings management
"""
from .admin import admin_router
from .vendor import vendor_router
__all__ = ["vendor_router"]
__all__ = ["admin_router", "vendor_router"]

View File

@@ -0,0 +1,19 @@
# app/modules/core/routes/api/admin.py
"""
Core module admin API routes.
Aggregates all admin core routes:
- /dashboard/* - Admin dashboard and statistics
- /settings/* - Platform settings management
"""
from fastapi import APIRouter
from .admin_dashboard import admin_dashboard_router
from .admin_settings import admin_settings_router
admin_router = APIRouter()
# Aggregate all core admin routes
admin_router.include_router(admin_dashboard_router, tags=["admin-dashboard"])
admin_router.include_router(admin_settings_router, tags=["admin-settings"])

View File

@@ -0,0 +1,127 @@
# app/modules/core/routes/api/admin_dashboard.py
"""
Admin dashboard and statistics endpoints.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.services.admin_service import admin_service
from app.modules.analytics.services.stats_service import stats_service
from models.schema.auth import UserContext
from app.modules.analytics.schemas import (
AdminDashboardResponse,
ImportStatsResponse,
MarketplaceStatsResponse,
OrderStatsBasicResponse,
PlatformStatsResponse,
ProductStatsResponse,
StatsResponse,
UserStatsResponse,
VendorStatsResponse,
)
admin_dashboard_router = APIRouter(prefix="/dashboard")
logger = logging.getLogger(__name__)
@admin_dashboard_router.get("", response_model=AdminDashboardResponse)
def get_admin_dashboard(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get admin dashboard with platform statistics (Admin only)."""
user_stats = stats_service.get_user_statistics(db)
vendor_stats = stats_service.get_vendor_statistics(db)
return AdminDashboardResponse(
platform={
"name": "Multi-Tenant Ecommerce Platform",
"version": "1.0.0",
},
users=UserStatsResponse(**user_stats),
vendors=VendorStatsResponse(
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
verified=vendor_stats.get(
"verified", vendor_stats.get("verified_vendors", 0)
),
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
inactive=vendor_stats.get(
"inactive", vendor_stats.get("inactive_vendors", 0)
),
),
recent_vendors=admin_service.get_recent_vendors(db, limit=5),
recent_imports=admin_service.get_recent_import_jobs(db, limit=10),
)
@admin_dashboard_router.get("/stats", response_model=StatsResponse)
def get_comprehensive_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get comprehensive platform statistics (Admin only)."""
stats_data = stats_service.get_comprehensive_stats(db=db)
return StatsResponse(
total_products=stats_data["total_products"],
unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"],
unique_vendors=stats_data["unique_vendors"],
total_inventory_entries=stats_data["total_inventory_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"],
)
@admin_dashboard_router.get("/stats/marketplace", response_model=list[MarketplaceStatsResponse])
def get_marketplace_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get statistics broken down by marketplace (Admin only)."""
marketplace_stats = stats_service.get_marketplace_breakdown_stats(db=db)
return [
MarketplaceStatsResponse(
marketplace=stat["marketplace"],
total_products=stat["total_products"],
unique_vendors=stat["unique_vendors"],
unique_brands=stat["unique_brands"],
)
for stat in marketplace_stats
]
@admin_dashboard_router.get("/stats/platform", response_model=PlatformStatsResponse)
def get_platform_statistics(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get comprehensive platform statistics (Admin only)."""
user_stats = stats_service.get_user_statistics(db)
vendor_stats = stats_service.get_vendor_statistics(db)
product_stats = stats_service.get_product_statistics(db)
order_stats = stats_service.get_order_statistics(db)
import_stats = stats_service.get_import_statistics(db)
return PlatformStatsResponse(
users=UserStatsResponse(**user_stats),
vendors=VendorStatsResponse(
total=vendor_stats.get("total", vendor_stats.get("total_vendors", 0)),
verified=vendor_stats.get(
"verified", vendor_stats.get("verified_vendors", 0)
),
pending=vendor_stats.get("pending", vendor_stats.get("pending_vendors", 0)),
inactive=vendor_stats.get(
"inactive", vendor_stats.get("inactive_vendors", 0)
),
),
products=ProductStatsResponse(**product_stats),
orders=OrderStatsBasicResponse(**order_stats),
imports=ImportStatsResponse(**import_stats),
)

View File

@@ -0,0 +1,716 @@
# app/modules/core/routes/api/admin_settings.py
"""
Platform settings management endpoints.
Provides endpoints for:
- Viewing all platform settings
- Creating/updating settings
- Managing configuration by category
- Email configuration status and testing
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.config import settings as app_settings
from app.core.database import get_db
from app.exceptions import ResourceNotFoundException
from app.modules.tenancy.exceptions import ConfirmationRequiredException
from app.modules.monitoring.services.admin_audit_service import admin_audit_service
from app.modules.core.services.admin_settings_service import admin_settings_service
from models.schema.auth import UserContext
from models.schema.admin import (
AdminSettingCreate,
AdminSettingDefaultResponse,
AdminSettingListResponse,
AdminSettingResponse,
AdminSettingUpdate,
PublicDisplaySettingsResponse,
RowsPerPageResponse,
RowsPerPageUpdateResponse,
)
admin_settings_router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__)
@admin_settings_router.get("", response_model=AdminSettingListResponse)
def get_all_settings(
category: str | None = Query(None, description="Filter by category"),
is_public: bool | None = Query(None, description="Filter by public flag"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get all platform settings.
Can be filtered by category (system, security, marketplace, notifications)
and by public flag (settings that can be exposed to frontend).
"""
settings = admin_settings_service.get_all_settings(db, category, is_public)
return AdminSettingListResponse(
settings=settings, total=len(settings), category=category
)
@admin_settings_router.get("/categories")
def get_setting_categories(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get list of all setting categories."""
# This could be enhanced to return counts per category
return {
"categories": [
"system",
"security",
"marketplace",
"notifications",
"integrations",
"payments",
]
}
@admin_settings_router.get("/{key}", response_model=AdminSettingResponse | AdminSettingDefaultResponse)
def get_setting(
key: str,
default: str | None = Query(None, description="Default value if setting not found"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AdminSettingResponse | AdminSettingDefaultResponse:
"""Get specific setting by key.
If `default` is provided and the setting doesn't exist, returns a response
with the default value instead of 404.
"""
setting = admin_settings_service.get_setting_by_key(db, key)
if not setting:
if default is not None:
# Return default value without creating the setting
return AdminSettingDefaultResponse(key=key, value=default, exists=False)
raise ResourceNotFoundException(resource_type="Setting", identifier=key)
return AdminSettingResponse.model_validate(setting)
@admin_settings_router.post("", response_model=AdminSettingResponse)
def create_setting(
setting_data: AdminSettingCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create new platform setting.
Setting keys should be lowercase with underscores (e.g., max_vendors_allowed).
"""
result = admin_settings_service.create_setting(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="create_setting",
target_type="setting",
target_id=setting_data.key,
details={
"category": setting_data.category,
"value_type": setting_data.value_type,
},
)
db.commit()
return result
@admin_settings_router.put("/{key}", response_model=AdminSettingResponse)
def update_setting(
key: str,
update_data: AdminSettingUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Update existing setting value."""
old_value = admin_settings_service.get_setting_value(db, key)
result = admin_settings_service.update_setting(
db=db, key=key, update_data=update_data, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_setting",
target_type="setting",
target_id=key,
details={"old_value": str(old_value), "new_value": update_data.value},
)
db.commit()
return result
@admin_settings_router.post("/upsert", response_model=AdminSettingResponse)
def upsert_setting(
setting_data: AdminSettingCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create or update setting (upsert).
If setting exists, updates its value. If not, creates new setting.
"""
result = admin_settings_service.upsert_setting(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="upsert_setting",
target_type="setting",
target_id=setting_data.key,
details={"category": setting_data.category},
)
db.commit()
return result
# ============================================================================
# CONVENIENCE ENDPOINTS FOR COMMON SETTINGS
# ============================================================================
@admin_settings_router.get("/display/rows-per-page", response_model=RowsPerPageResponse)
def get_rows_per_page(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> RowsPerPageResponse:
"""Get the platform-wide rows per page setting."""
value = admin_settings_service.get_setting_value(db, "rows_per_page", default="20")
return RowsPerPageResponse(rows_per_page=int(value))
@admin_settings_router.put("/display/rows-per-page", response_model=RowsPerPageUpdateResponse)
def set_rows_per_page(
rows: int = Query(..., ge=10, le=100, description="Rows per page (10-100)"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> RowsPerPageUpdateResponse:
"""
Set the platform-wide rows per page setting.
Valid values: 10, 20, 50, 100
"""
valid_values = [10, 20, 50, 100]
if rows not in valid_values:
# Round to nearest valid value
rows = min(valid_values, key=lambda x: abs(x - rows))
setting_data = AdminSettingCreate(
key="rows_per_page",
value=str(rows),
value_type="integer",
category="display",
description="Default number of rows per page in admin tables",
is_public=True,
)
admin_settings_service.upsert_setting(
db=db, setting_data=setting_data, admin_user_id=current_admin.id
)
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_setting",
target_type="setting",
target_id="rows_per_page",
details={"value": rows},
)
db.commit()
return RowsPerPageUpdateResponse(
rows_per_page=rows, message="Rows per page setting updated"
)
@admin_settings_router.get("/display/public", response_model=PublicDisplaySettingsResponse)
def get_public_display_settings(
db: Session = Depends(get_db),
) -> PublicDisplaySettingsResponse:
"""
Get public display settings (no auth required).
Returns settings that can be used by frontend without admin auth.
"""
rows_per_page = admin_settings_service.get_setting_value(
db, "rows_per_page", default="20"
)
return PublicDisplaySettingsResponse(rows_per_page=int(rows_per_page))
@admin_settings_router.delete("/{key}")
def delete_setting(
key: str,
confirm: bool = Query(False, description="Must be true to confirm deletion"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Delete platform setting.
Requires confirmation parameter.
WARNING: Deleting settings may affect platform functionality.
"""
if not confirm:
raise ConfirmationRequiredException(
operation="delete_setting",
message="Deletion requires confirmation parameter: confirm=true",
)
message = admin_settings_service.delete_setting(
db=db, key=key, admin_user_id=current_admin.id
)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="delete_setting",
target_type="setting",
target_id=key,
details={},
)
db.commit()
return {"message": message}
# ============================================================================
# EMAIL CONFIGURATION ENDPOINTS
# ============================================================================
# Email setting keys stored in admin_settings table
EMAIL_SETTING_KEYS = {
"email_provider": "smtp",
"email_from_address": "",
"email_from_name": "",
"email_reply_to": "",
"smtp_host": "",
"smtp_port": "587",
"smtp_user": "",
"smtp_password": "",
"smtp_use_tls": "true",
"smtp_use_ssl": "false",
"sendgrid_api_key": "",
"mailgun_api_key": "",
"mailgun_domain": "",
"aws_access_key_id": "",
"aws_secret_access_key": "",
"aws_region": "eu-west-1",
"email_enabled": "true",
"email_debug": "false",
}
def get_email_setting(db: Session, key: str) -> str | None:
"""Get email setting from database, returns None if not set."""
setting = admin_settings_service.get_setting_by_key(db, key)
return setting.value if setting else None
def get_effective_email_config(db: Session) -> dict:
"""
Get effective email configuration.
Priority: Database settings > Environment variables
"""
config = {}
# Provider
db_provider = get_email_setting(db, "email_provider")
config["provider"] = db_provider if db_provider else app_settings.email_provider
# From settings
db_from_email = get_email_setting(db, "email_from_address")
config["from_email"] = db_from_email if db_from_email else app_settings.email_from_address
db_from_name = get_email_setting(db, "email_from_name")
config["from_name"] = db_from_name if db_from_name else app_settings.email_from_name
db_reply_to = get_email_setting(db, "email_reply_to")
config["reply_to"] = db_reply_to if db_reply_to else app_settings.email_reply_to
# SMTP settings
db_smtp_host = get_email_setting(db, "smtp_host")
config["smtp_host"] = db_smtp_host if db_smtp_host else app_settings.smtp_host
db_smtp_port = get_email_setting(db, "smtp_port")
config["smtp_port"] = int(db_smtp_port) if db_smtp_port else app_settings.smtp_port
db_smtp_user = get_email_setting(db, "smtp_user")
config["smtp_user"] = db_smtp_user if db_smtp_user else app_settings.smtp_user
db_smtp_password = get_email_setting(db, "smtp_password")
config["smtp_password"] = db_smtp_password if db_smtp_password else app_settings.smtp_password
db_smtp_use_tls = get_email_setting(db, "smtp_use_tls")
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else app_settings.smtp_use_tls
db_smtp_use_ssl = get_email_setting(db, "smtp_use_ssl")
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else app_settings.smtp_use_ssl
# SendGrid
db_sendgrid_key = get_email_setting(db, "sendgrid_api_key")
config["sendgrid_api_key"] = db_sendgrid_key if db_sendgrid_key else app_settings.sendgrid_api_key
# Mailgun
db_mailgun_key = get_email_setting(db, "mailgun_api_key")
config["mailgun_api_key"] = db_mailgun_key if db_mailgun_key else app_settings.mailgun_api_key
db_mailgun_domain = get_email_setting(db, "mailgun_domain")
config["mailgun_domain"] = db_mailgun_domain if db_mailgun_domain else app_settings.mailgun_domain
# AWS SES
db_aws_key = get_email_setting(db, "aws_access_key_id")
config["aws_access_key_id"] = db_aws_key if db_aws_key else app_settings.aws_access_key_id
db_aws_secret = get_email_setting(db, "aws_secret_access_key")
config["aws_secret_access_key"] = db_aws_secret if db_aws_secret else app_settings.aws_secret_access_key
db_aws_region = get_email_setting(db, "aws_region")
config["aws_region"] = db_aws_region if db_aws_region else app_settings.aws_region
# Behavior
db_enabled = get_email_setting(db, "email_enabled")
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else app_settings.email_enabled
db_debug = get_email_setting(db, "email_debug")
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else app_settings.email_debug
# Track source for each field (DB override or .env)
config["_sources"] = {}
for key in ["provider", "from_email", "from_name", "smtp_host", "smtp_port"]:
db_key = "email_provider" if key == "provider" else ("email_from_address" if key == "from_email" else ("email_from_name" if key == "from_name" else key))
config["_sources"][key] = "database" if get_email_setting(db, db_key) else "env"
return config
class EmailStatusResponse(BaseModel):
"""Platform email configuration status."""
provider: str
from_email: str
from_name: str
reply_to: str | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
mailgun_domain: str | None = None
aws_region: str | None = None
debug: bool
enabled: bool
is_configured: bool
has_db_overrides: bool = False
class EmailSettingsUpdate(BaseModel):
"""Update email settings."""
provider: str | None = None
from_email: EmailStr | None = None
from_name: str | None = None
reply_to: EmailStr | None = None
# SMTP
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
smtp_password: str | None = None
smtp_use_tls: bool | None = None
smtp_use_ssl: bool | None = None
# SendGrid
sendgrid_api_key: str | None = None
# Mailgun
mailgun_api_key: str | None = None
mailgun_domain: str | None = None
# AWS SES
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None
aws_region: str | None = None
# Behavior
enabled: bool | None = None
debug: bool | None = None
class TestEmailRequest(BaseModel):
"""Request body for test email."""
to_email: EmailStr
class TestEmailResponse(BaseModel):
"""Response for test email."""
success: bool
message: str
@admin_settings_router.get("/email/status", response_model=EmailStatusResponse)
def get_email_status(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> EmailStatusResponse:
"""
Get platform email configuration status.
Returns the effective email configuration (DB overrides > .env).
Sensitive values (passwords, API keys) are NOT exposed.
"""
config = get_effective_email_config(db)
provider = config["provider"].lower()
# Determine if email is configured based on provider
is_configured = False
if provider == "smtp":
is_configured = bool(config["smtp_host"] and config["smtp_host"] != "localhost")
elif provider == "sendgrid":
is_configured = bool(config["sendgrid_api_key"])
elif provider == "mailgun":
is_configured = bool(config["mailgun_api_key"] and config["mailgun_domain"])
elif provider == "ses":
is_configured = bool(config["aws_access_key_id"] and config["aws_secret_access_key"])
# Check if any DB overrides exist
has_db_overrides = any(v == "database" for v in config["_sources"].values())
return EmailStatusResponse(
provider=provider,
from_email=config["from_email"],
from_name=config["from_name"],
reply_to=config["reply_to"] or None,
smtp_host=config["smtp_host"] if provider == "smtp" else None,
smtp_port=config["smtp_port"] if provider == "smtp" else None,
smtp_user=config["smtp_user"] if provider == "smtp" else None,
mailgun_domain=config["mailgun_domain"] if provider == "mailgun" else None,
aws_region=config["aws_region"] if provider == "ses" else None,
debug=config["debug"],
enabled=config["enabled"],
is_configured=is_configured,
has_db_overrides=has_db_overrides,
)
@admin_settings_router.put("/email/settings")
def update_email_settings(
settings_update: EmailSettingsUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update platform email settings.
Settings are stored in the database and override .env values.
Only non-null values are updated.
"""
from models.schema.admin import AdminSettingCreate
updated_keys = []
# Map request fields to database keys
field_mappings = {
"provider": ("email_provider", "string"),
"from_email": ("email_from_address", "string"),
"from_name": ("email_from_name", "string"),
"reply_to": ("email_reply_to", "string"),
"smtp_host": ("smtp_host", "string"),
"smtp_port": ("smtp_port", "integer"),
"smtp_user": ("smtp_user", "string"),
"smtp_password": ("smtp_password", "string"),
"smtp_use_tls": ("smtp_use_tls", "boolean"),
"smtp_use_ssl": ("smtp_use_ssl", "boolean"),
"sendgrid_api_key": ("sendgrid_api_key", "string"),
"mailgun_api_key": ("mailgun_api_key", "string"),
"mailgun_domain": ("mailgun_domain", "string"),
"aws_access_key_id": ("aws_access_key_id", "string"),
"aws_secret_access_key": ("aws_secret_access_key", "string"),
"aws_region": ("aws_region", "string"),
"enabled": ("email_enabled", "boolean"),
"debug": ("email_debug", "boolean"),
}
# Sensitive fields that should be marked as encrypted
sensitive_keys = {
"smtp_password", "sendgrid_api_key", "mailgun_api_key",
"aws_access_key_id", "aws_secret_access_key"
}
for field, (db_key, value_type) in field_mappings.items():
value = getattr(settings_update, field, None)
if value is not None:
# Convert value to string for storage
if value_type == "boolean":
str_value = "true" if value else "false"
elif value_type == "integer":
str_value = str(value)
else:
str_value = str(value)
# Create or update setting
setting_data = AdminSettingCreate(
key=db_key,
value=str_value,
value_type=value_type,
category="email",
description=f"Email setting: {field}",
is_encrypted=db_key in sensitive_keys,
is_public=False,
)
admin_settings_service.upsert_setting(db, setting_data, current_admin.id)
updated_keys.append(field)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_email_settings",
target_type="email_settings",
target_id="platform",
details={"updated_keys": updated_keys},
)
db.commit()
logger.info(f"Email settings updated by admin {current_admin.id}: {updated_keys}")
return {
"success": True,
"message": f"Updated {len(updated_keys)} email setting(s)",
"updated_keys": updated_keys,
}
@admin_settings_router.delete("/email/settings")
def reset_email_settings(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Reset email settings to use .env values.
Deletes all email settings from the database, reverting to .env configuration.
"""
deleted_count = 0
for key in EMAIL_SETTING_KEYS:
setting = admin_settings_service.get_setting_by_key(db, key)
if setting:
# Use service method for deletion (API-002 compliance)
admin_settings_service.delete_setting(db, key, current_admin.id)
deleted_count += 1
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="reset_email_settings",
target_type="email_settings",
target_id="platform",
details={"deleted_count": deleted_count},
)
db.commit()
logger.info(f"Email settings reset by admin {current_admin.id}, deleted {deleted_count} settings")
return {
"success": True,
"message": f"Reset {deleted_count} email setting(s) to .env defaults",
}
@admin_settings_router.post("/email/test", response_model=TestEmailResponse)
def send_test_email(
request: TestEmailRequest,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> TestEmailResponse:
"""
Send a test email using the platform email configuration.
This tests the email provider configuration from environment variables.
"""
from app.modules.messaging.services.email_service import EmailService
try:
email_service = EmailService(db)
# Send test email using platform configuration
email_log = email_service.send_raw(
to_email=request.to_email,
to_name=None,
subject="Wizamart Platform - Test Email",
body_html="""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #6b46c1;">Test Email from Wizamart</h2>
<p>This is a test email to verify your platform email configuration.</p>
<p>If you received this email, your email settings are working correctly!</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Provider: {provider}<br>
From: {from_email}
</p>
</body>
</html>
""".format(
provider=app_settings.email_provider,
from_email=app_settings.email_from_address,
),
body_text=f"Test email from Wizamart platform.\n\nProvider: {app_settings.email_provider}\nFrom: {app_settings.email_from_address}",
is_platform_email=True,
)
# Check if email was actually sent (send_raw returns EmailLog, not boolean)
if email_log.status == "sent":
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="send_test_email",
target_type="email",
target_id=request.to_email,
details={"provider": app_settings.email_provider},
)
db.commit()
return TestEmailResponse(
success=True,
message=f"Test email sent to {request.to_email}",
)
else:
return TestEmailResponse(
success=False,
message=email_log.error_message or "Failed to send test email. Check server logs for details.",
)
except Exception as e:
logger.error(f"Failed to send test email: {e}")
return TestEmailResponse(
success=False,
message=f"Error sending test email: {str(e)}",
)

View File

@@ -0,0 +1,190 @@
# app/modules/core/routes/api/public.py
"""
Public language API endpoints.
Handles:
- Setting language preference via cookie
- Getting current language info
- Listing available languages
All endpoints are public (no authentication required).
"""
import logging
from fastapi import APIRouter, Request, Response
from pydantic import BaseModel, Field
from app.utils.i18n import (
DEFAULT_LANGUAGE,
LANGUAGE_FLAGS,
LANGUAGE_NAMES,
LANGUAGE_NAMES_EN,
SUPPORTED_LANGUAGES,
get_language_info,
)
from middleware.language import LANGUAGE_COOKIE_NAME, set_language_cookie
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/language")
# =============================================================================
# Schemas
# =============================================================================
class SetLanguageRequest(BaseModel):
"""Request body for setting language preference."""
language: str = Field(
...,
description="Language code (en, fr, de, lb)",
min_length=2,
max_length=5,
)
class SetLanguageResponse(BaseModel):
"""Response after setting language preference."""
success: bool
language: str
message: str
class LanguageInfo(BaseModel):
"""Information about a single language."""
code: str
name: str
name_en: str
flag: str
class LanguageListResponse(BaseModel):
"""Response listing all available languages."""
languages: list[LanguageInfo]
current: str
default: str
class CurrentLanguageResponse(BaseModel):
"""Response with current language information."""
code: str
name: str
name_en: str
flag: str
source: str # Where the language was determined from (cookie, browser, default)
# =============================================================================
# Endpoints
# =============================================================================
@router.post("/set", response_model=SetLanguageResponse) # public
async def set_language(
request: Request,
response: Response,
body: SetLanguageRequest,
) -> SetLanguageResponse:
"""
Set the user's language preference.
This sets a cookie that will be used for subsequent requests.
The page should be reloaded after calling this endpoint.
"""
language = body.language.lower()
if language not in SUPPORTED_LANGUAGES:
return SetLanguageResponse(
success=False,
language=language,
message=f"Unsupported language: {language}. Supported: {', '.join(SUPPORTED_LANGUAGES)}",
)
# Set language cookie
set_language_cookie(response, language)
logger.info(f"Language preference set to: {language}")
return SetLanguageResponse(
success=True,
language=language,
message=f"Language set to {LANGUAGE_NAMES.get(language, language)}",
)
@router.get("/current", response_model=CurrentLanguageResponse) # public
async def get_current_language(request: Request) -> CurrentLanguageResponse:
"""
Get the current language for this request.
Returns information about the detected language and where it came from.
"""
# Get language from request state (set by middleware)
language = getattr(request.state, "language", DEFAULT_LANGUAGE)
language_info = getattr(request.state, "language_info", {})
# Determine source
source = "default"
if language_info.get("cookie"):
source = "cookie"
elif language_info.get("browser"):
source = "browser"
info = get_language_info(language)
return CurrentLanguageResponse(
code=info["code"],
name=info["name"],
name_en=info["name_en"],
flag=info["flag"],
source=source,
)
@router.get("/list", response_model=LanguageListResponse) # public
async def list_languages(request: Request) -> LanguageListResponse:
"""
List all available languages.
Returns all supported languages with their display names.
"""
current = getattr(request.state, "language", DEFAULT_LANGUAGE)
languages = [
LanguageInfo(
code=code,
name=LANGUAGE_NAMES.get(code, code),
name_en=LANGUAGE_NAMES_EN.get(code, code),
flag=LANGUAGE_FLAGS.get(code, ""),
)
for code in SUPPORTED_LANGUAGES
]
return LanguageListResponse(
languages=languages,
current=current,
default=DEFAULT_LANGUAGE,
)
@router.delete("/clear") # public
async def clear_language(response: Response) -> SetLanguageResponse:
"""
Clear the language preference cookie.
After clearing, the language will be determined from browser settings or defaults.
"""
response.delete_cookie(key=LANGUAGE_COOKIE_NAME)
return SetLanguageResponse(
success=True,
language=DEFAULT_LANGUAGE,
message="Language preference cleared. Using browser/default language.",
)

View File

@@ -13,9 +13,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import VendorNotActiveException
from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from app.modules.tenancy.exceptions import VendorNotActiveException
from app.modules.analytics.services.stats_service import stats_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.analytics.schemas import (
VendorCustomerStats,

View File

@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.platform_settings_service import platform_settings_service
from app.services.vendor_service import vendor_service
from app.modules.core.services.platform_settings_service import platform_settings_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
vendor_settings_router = APIRouter(prefix="/settings")

View File

@@ -0,0 +1,2 @@
# app/modules/core/routes/pages/__init__.py
"""Core module page routes."""

View File

@@ -0,0 +1,159 @@
# app/modules/core/routes/pages/admin.py
"""
Core Admin Page Routes (HTML rendering).
Admin pages for core platform functionality:
- Login, logout, authentication
- Dashboard
- Settings
- Feature management
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_optional, get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates
from models.database.admin_menu_config import FrontendType
from models.database.user import User
router = APIRouter()
# ============================================================================
# PUBLIC ROUTES (No Authentication Required)
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def admin_root(
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Redirect /admin/ based on authentication status.
- Authenticated admin users -> /admin/dashboard
- Unauthenticated users -> /admin/login
"""
if current_user:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return RedirectResponse(url="/admin/login", status_code=302)
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def admin_login_page(
request: Request,
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Render admin login page.
If user is already authenticated as admin, redirect to dashboard.
Otherwise, show login form.
"""
if current_user:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse("tenancy/admin/login.html", {"request": request})
@router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False)
async def admin_select_platform_page(
request: Request,
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Render platform selection page for platform admins.
Platform admins with access to multiple platforms must select
which platform they want to manage before accessing the dashboard.
Super admins are redirected to dashboard (they have global access).
"""
if not current_user:
return RedirectResponse(url="/admin/login", status_code=302)
if current_user.is_super_admin:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse(
"tenancy/admin/select-platform.html",
{"request": request, "user": current_user},
)
# ============================================================================
# AUTHENTICATED ROUTES (Admin Only)
# ============================================================================
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def admin_dashboard_page(
request: Request,
current_user: User = Depends(require_menu_access("dashboard", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render admin dashboard page.
Shows platform statistics and recent activity.
"""
return templates.TemplateResponse(
"core/admin/dashboard.html",
get_admin_context(request, current_user),
)
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False)
async def admin_settings_page(
request: Request,
current_user: User = Depends(require_menu_access("settings", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render admin settings page.
Platform configuration and preferences.
"""
return templates.TemplateResponse(
"core/admin/settings.html",
get_admin_context(request, current_user),
)
@router.get("/my-menu", response_class=HTMLResponse, include_in_schema=False)
async def admin_my_menu_config(
request: Request,
current_user: User = Depends(require_menu_access("my-menu", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render personal menu configuration page for super admins.
Allows super admins to customize their own sidebar menu.
"""
# Only super admins can configure their own menu
if not current_user.is_super_admin:
return RedirectResponse(url="/admin/settings", status_code=302)
return templates.TemplateResponse(
"core/admin/my-menu-config.html",
get_admin_context(request, current_user),
)
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
async def admin_features_page(
request: Request,
current_user: User = Depends(
require_menu_access("subscription-tiers", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render feature management page.
Shows all features with tier assignments and allows editing.
"""
return templates.TemplateResponse(
"billing/admin/features.html",
get_admin_context(request, current_user),
)

Some files were not shown because too many files have changed in this diff Show More