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:
@@ -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."""
|
||||
|
||||
87
app/modules/analytics/routes/pages/admin.py
Normal file
87
app/modules/analytics/routes/pages/admin.py
Normal 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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
247
app/modules/billing/routes/api/public.py
Normal file
247
app/modules/billing/routes/api/public.py
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
80
app/modules/billing/routes/pages/admin.py
Normal file
80
app/modules/billing/routes/pages/admin.py
Normal 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),
|
||||
)
|
||||
121
app/modules/billing/routes/pages/public.py
Normal file
121
app/modules/billing/routes/pages/public.py
Normal 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,
|
||||
)
|
||||
62
app/modules/billing/routes/pages/vendor.py
Normal file
62
app/modules/billing/routes/pages/vendor.py
Normal 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),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
321
app/modules/billing/services/capacity_forecast_service.py
Normal file
321
app/modules/billing/services/capacity_forecast_service.py
Normal 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()
|
||||
590
app/modules/billing/services/feature_service.py
Normal file
590
app/modules/billing/services/feature_service.py
Normal 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",
|
||||
]
|
||||
94
app/modules/billing/services/platform_pricing_service.py
Normal file
94
app/modules/billing/services/platform_pricing_service.py
Normal 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()
|
||||
207
app/modules/billing/templates/billing/admin/billing-history.html
Normal file
207
app/modules/billing/templates/billing/admin/billing-history.html
Normal 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 %}
|
||||
355
app/modules/billing/templates/billing/admin/features.html
Normal file
355
app/modules/billing/templates/billing/admin/features.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
329
app/modules/billing/templates/billing/admin/subscriptions.html
Normal file
329
app/modules/billing/templates/billing/admin/subscriptions.html
Normal 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 || '∞'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span x-text="sub.products_limit || '∞'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span x-text="sub.team_members_limit || '∞'"></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 %}
|
||||
119
app/modules/billing/templates/billing/public/pricing.html
Normal file
119
app/modules/billing/templates/billing/public/pricing.html
Normal 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">
|
||||
← {{ _("platform.pricing.back_home") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
534
app/modules/billing/templates/billing/public/signup.html
Normal file
534
app/modules/billing/templates/billing/public/signup.html
Normal 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 %}
|
||||
•
|
||||
{% 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 %}
|
||||
428
app/modules/billing/templates/billing/vendor/billing.html
vendored
Normal file
428
app/modules/billing/templates/billing/vendor/billing.html
vendored
Normal 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 %}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
2
app/modules/cart/routes/pages/__init__.py
Normal file
2
app/modules/cart/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/cart/routes/pages/__init__.py
|
||||
"""Cart module page routes."""
|
||||
46
app/modules/cart/routes/pages/storefront.py
Normal file
46
app/modules/cart/routes/pages/storefront.py
Normal 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)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
319
app/modules/cart/templates/cart/storefront/cart.html
Normal file
319
app/modules/cart/templates/cart/storefront/cart.html
Normal 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 %}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
2
app/modules/catalog/routes/pages/__init__.py
Normal file
2
app/modules/catalog/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/catalog/routes/pages/__init__.py
|
||||
"""Catalog module page routes."""
|
||||
110
app/modules/catalog/routes/pages/admin.py
Normal file
110
app/modules/catalog/routes/pages/admin.py
Normal 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),
|
||||
)
|
||||
171
app/modules/catalog/routes/pages/storefront.py
Normal file
171
app/modules/catalog/routes/pages/storefront.py
Normal 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),
|
||||
)
|
||||
64
app/modules/catalog/routes/pages/vendor.py
Normal file
64
app/modules/catalog/routes/pages/vendor.py
Normal 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),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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__)
|
||||
|
||||
335
app/modules/catalog/services/product_service.py
Normal file
335
app/modules/catalog/services/product_service.py
Normal 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()
|
||||
484
app/modules/catalog/services/vendor_product_service.py
Normal file
484
app/modules/catalog/services/vendor_product_service.py
Normal 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()
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
400
app/modules/catalog/templates/catalog/admin/vendor-products.html
Normal file
400
app/modules/catalog/templates/catalog/admin/vendor-products.html
Normal 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 %}
|
||||
281
app/modules/catalog/templates/catalog/storefront/category.html
Normal file
281
app/modules/catalog/templates/catalog/storefront/category.html
Normal 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 %}
|
||||
426
app/modules/catalog/templates/catalog/storefront/product.html
Normal file
426
app/modules/catalog/templates/catalog/storefront/product.html
Normal 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 %}
|
||||
246
app/modules/catalog/templates/catalog/storefront/products.html
Normal file
246
app/modules/catalog/templates/catalog/storefront/products.html
Normal 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 %}
|
||||
327
app/modules/catalog/templates/catalog/storefront/search.html
Normal file
327
app/modules/catalog/templates/catalog/storefront/search.html
Normal 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 %}
|
||||
252
app/modules/catalog/templates/catalog/storefront/wishlist.html
Normal file
252
app/modules/catalog/templates/catalog/storefront/wishlist.html
Normal 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 %}
|
||||
174
app/modules/catalog/templates/catalog/vendor/product-create.html
vendored
Normal file
174
app/modules/catalog/templates/catalog/vendor/product-create.html
vendored
Normal 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 %}
|
||||
368
app/modules/catalog/templates/catalog/vendor/products.html
vendored
Normal file
368
app/modules/catalog/templates/catalog/vendor/products.html
vendored
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
2
app/modules/checkout/routes/pages/__init__.py
Normal file
2
app/modules/checkout/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/checkout/routes/pages/__init__.py
|
||||
"""Checkout module page routes."""
|
||||
46
app/modules/checkout/routes/pages/storefront.py
Normal file
46
app/modules/checkout/routes/pages/storefront.py
Normal 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)
|
||||
)
|
||||
915
app/modules/checkout/templates/checkout/storefront/checkout.html
Normal file
915
app/modules/checkout/templates/checkout/storefront/checkout.html
Normal 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 %}
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
302
app/modules/cms/routes/api/admin_content_pages.py
Normal file
302
app/modules/cms/routes/api/admin_content_pages.py
Normal 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,
|
||||
}
|
||||
99
app/modules/cms/routes/api/admin_images.py
Normal file
99
app/modules/cms/routes/api/admin_images.py
Normal 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)
|
||||
138
app/modules/cms/routes/api/admin_media.py
Normal file
138
app/modules/cms/routes/api/admin_media.py
Normal 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"}
|
||||
234
app/modules/cms/routes/api/admin_vendor_themes.py
Normal file
234
app/modules/cms/routes/api/admin_vendor_themes.py
Normal 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")
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
241
app/modules/cms/routes/pages/public.py
Normal file
241
app/modules/cms/routes/pages/public.py
Normal 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,
|
||||
)
|
||||
175
app/modules/cms/routes/pages/storefront.py
Normal file
175
app/modules/cms/routes/pages/storefront.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
552
app/modules/cms/services/media_service.py
Normal file
552
app/modules/cms/services/media_service.py
Normal 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()
|
||||
483
app/modules/cms/services/vendor_email_settings_service.py
Normal file
483
app/modules/cms/services/vendor_email_settings_service.py
Normal 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)
|
||||
488
app/modules/cms/services/vendor_theme_service.py
Normal file
488
app/modules/cms/services/vendor_theme_service.py
Normal 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()
|
||||
307
app/modules/cms/static/shared/js/media-picker.js
Normal file
307
app/modules/cms/static/shared/js/media-picker.js
Normal 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 };
|
||||
}
|
||||
246
app/modules/cms/templates/cms/public/content-page.html
Normal file
246
app/modules/cms/templates/cms/public/content-page.html
Normal 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 %}
|
||||
134
app/modules/cms/templates/cms/public/homepage-default.html
Normal file
134
app/modules/cms/templates/cms/public/homepage-default.html
Normal 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 %}
|
||||
100
app/modules/cms/templates/cms/public/homepage-minimal.html
Normal file
100
app/modules/cms/templates/cms/public/homepage-minimal.html
Normal 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 %}
|
||||
598
app/modules/cms/templates/cms/public/homepage-modern.html
Normal file
598
app/modules/cms/templates/cms/public/homepage-modern.html
Normal 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 %}
|
||||
427
app/modules/cms/templates/cms/public/homepage-wizamart.html
Normal file
427
app/modules/cms/templates/cms/public/homepage-wizamart.html
Normal 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 %}
|
||||
54
app/modules/cms/templates/cms/public/sections/_cta.html
Normal file
54
app/modules/cms/templates/cms/public/sections/_cta.html
Normal 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 %}
|
||||
72
app/modules/cms/templates/cms/public/sections/_features.html
Normal file
72
app/modules/cms/templates/cms/public/sections/_features.html
Normal 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 %}
|
||||
71
app/modules/cms/templates/cms/public/sections/_hero.html
Normal file
71
app/modules/cms/templates/cms/public/sections/_hero.html
Normal 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 %}
|
||||
116
app/modules/cms/templates/cms/public/sections/_pricing.html
Normal file
116
app/modules/cms/templates/cms/public/sections/_pricing.html
Normal 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 %}
|
||||
79
app/modules/cms/templates/cms/storefront/content-page.html
Normal file
79
app/modules/cms/templates/cms/storefront/content-page.html
Normal 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 %}
|
||||
126
app/modules/cms/templates/cms/storefront/landing-default.html
Normal file
126
app/modules/cms/templates/cms/storefront/landing-default.html
Normal 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 %}
|
||||
258
app/modules/cms/templates/cms/storefront/landing-full.html
Normal file
258
app/modules/cms/templates/cms/storefront/landing-full.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
194
app/modules/cms/templates/cms/storefront/landing-modern.html
Normal file
194
app/modules/cms/templates/cms/storefront/landing-modern.html
Normal 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 %}
|
||||
445
app/modules/cms/templates/cms/vendor/media.html
vendored
Normal file
445
app/modules/cms/templates/cms/vendor/media.html
vendored
Normal 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 %}
|
||||
@@ -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"]
|
||||
|
||||
19
app/modules/core/routes/api/admin.py
Normal file
19
app/modules/core/routes/api/admin.py
Normal 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"])
|
||||
127
app/modules/core/routes/api/admin_dashboard.py
Normal file
127
app/modules/core/routes/api/admin_dashboard.py
Normal 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),
|
||||
)
|
||||
716
app/modules/core/routes/api/admin_settings.py
Normal file
716
app/modules/core/routes/api/admin_settings.py
Normal 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)}",
|
||||
)
|
||||
190
app/modules/core/routes/api/public.py
Normal file
190
app/modules/core/routes/api/public.py
Normal 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.",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
2
app/modules/core/routes/pages/__init__.py
Normal file
2
app/modules/core/routes/pages/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/modules/core/routes/pages/__init__.py
|
||||
"""Core module page routes."""
|
||||
159
app/modules/core/routes/pages/admin.py
Normal file
159
app/modules/core/routes/pages/admin.py
Normal 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
Reference in New Issue
Block a user