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:
@@ -2,23 +2,20 @@
|
||||
"""
|
||||
Customers module exceptions.
|
||||
|
||||
Re-exports customer-related exceptions from their source locations.
|
||||
This module provides exception classes for customer operations including:
|
||||
- Customer management (create, update, authentication)
|
||||
- Address management
|
||||
- Password reset
|
||||
"""
|
||||
|
||||
from app.exceptions.customer import (
|
||||
CustomerNotFoundException,
|
||||
CustomerAlreadyExistsException,
|
||||
DuplicateCustomerEmailException,
|
||||
CustomerNotActiveException,
|
||||
InvalidCustomerCredentialsException,
|
||||
CustomerValidationException,
|
||||
CustomerAuthorizationException,
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
from app.exceptions.address import (
|
||||
AddressNotFoundException,
|
||||
AddressLimitExceededException,
|
||||
InvalidAddressTypeException,
|
||||
from app.exceptions.base import (
|
||||
AuthenticationException,
|
||||
BusinessLogicException,
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -30,8 +27,155 @@ __all__ = [
|
||||
"InvalidCustomerCredentialsException",
|
||||
"CustomerValidationException",
|
||||
"CustomerAuthorizationException",
|
||||
"InvalidPasswordResetTokenException",
|
||||
"PasswordTooShortException",
|
||||
# Address exceptions
|
||||
"AddressNotFoundException",
|
||||
"AddressLimitExceededException",
|
||||
"InvalidAddressTypeException",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Customer Exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CustomerNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a customer is not found."""
|
||||
|
||||
def __init__(self, customer_identifier: str):
|
||||
super().__init__(
|
||||
resource_type="Customer",
|
||||
identifier=customer_identifier,
|
||||
message=f"Customer '{customer_identifier}' not found",
|
||||
error_code="CUSTOMER_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class CustomerAlreadyExistsException(ConflictException):
|
||||
"""Raised when trying to create a customer that already exists."""
|
||||
|
||||
def __init__(self, email: str):
|
||||
super().__init__(
|
||||
message=f"Customer with email '{email}' already exists",
|
||||
error_code="CUSTOMER_ALREADY_EXISTS",
|
||||
details={"email": email},
|
||||
)
|
||||
|
||||
|
||||
class DuplicateCustomerEmailException(ConflictException):
|
||||
"""Raised when email already exists for vendor."""
|
||||
|
||||
def __init__(self, email: str, vendor_code: str):
|
||||
super().__init__(
|
||||
message=f"Email '{email}' is already registered for this vendor",
|
||||
error_code="DUPLICATE_CUSTOMER_EMAIL",
|
||||
details={"email": email, "vendor_code": vendor_code},
|
||||
)
|
||||
|
||||
|
||||
class CustomerNotActiveException(BusinessLogicException):
|
||||
"""Raised when trying to perform operations on inactive customer."""
|
||||
|
||||
def __init__(self, email: str):
|
||||
super().__init__(
|
||||
message=f"Customer account '{email}' is not active",
|
||||
error_code="CUSTOMER_NOT_ACTIVE",
|
||||
details={"email": email},
|
||||
)
|
||||
|
||||
|
||||
class InvalidCustomerCredentialsException(AuthenticationException):
|
||||
"""Raised when customer credentials are invalid."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="Invalid email or password",
|
||||
error_code="INVALID_CUSTOMER_CREDENTIALS",
|
||||
)
|
||||
|
||||
|
||||
class CustomerValidationException(ValidationException):
|
||||
"""Raised when customer data validation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Customer validation failed",
|
||||
field: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
):
|
||||
super().__init__(message=message, field=field, details=details)
|
||||
self.error_code = "CUSTOMER_VALIDATION_FAILED"
|
||||
|
||||
|
||||
class CustomerAuthorizationException(BusinessLogicException):
|
||||
"""Raised when customer is not authorized for operation."""
|
||||
|
||||
def __init__(self, customer_email: str, operation: str):
|
||||
super().__init__(
|
||||
message=f"Customer '{customer_email}' not authorized for: {operation}",
|
||||
error_code="CUSTOMER_NOT_AUTHORIZED",
|
||||
details={"customer_email": customer_email, "operation": operation},
|
||||
)
|
||||
|
||||
|
||||
class InvalidPasswordResetTokenException(ValidationException):
|
||||
"""Raised when password reset token is invalid or expired."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="Invalid or expired password reset link. Please request a new one.",
|
||||
field="reset_token",
|
||||
)
|
||||
self.error_code = "INVALID_RESET_TOKEN"
|
||||
|
||||
|
||||
class PasswordTooShortException(ValidationException):
|
||||
"""Raised when password doesn't meet minimum length requirement."""
|
||||
|
||||
def __init__(self, min_length: int = 8):
|
||||
super().__init__(
|
||||
message=f"Password must be at least {min_length} characters long",
|
||||
field="password",
|
||||
details={"min_length": min_length},
|
||||
)
|
||||
self.error_code = "PASSWORD_TOO_SHORT"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Address Exceptions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AddressNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a customer address is not found."""
|
||||
|
||||
def __init__(self, address_id: str | int):
|
||||
super().__init__(
|
||||
resource_type="Address",
|
||||
identifier=str(address_id),
|
||||
error_code="ADDRESS_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class AddressLimitExceededException(BusinessLogicException):
|
||||
"""Raised when customer exceeds maximum number of addresses."""
|
||||
|
||||
def __init__(self, max_addresses: int = 10):
|
||||
super().__init__(
|
||||
message=f"Maximum number of addresses ({max_addresses}) reached",
|
||||
error_code="ADDRESS_LIMIT_EXCEEDED",
|
||||
details={"max_addresses": max_addresses},
|
||||
)
|
||||
|
||||
|
||||
class InvalidAddressTypeException(BusinessLogicException):
|
||||
"""Raised when an invalid address type is provided."""
|
||||
|
||||
def __init__(self, address_type: str):
|
||||
super().__init__(
|
||||
message=f"Invalid address type '{address_type}'. Must be 'shipping' or 'billing'",
|
||||
error_code="INVALID_ADDRESS_TYPE",
|
||||
details={"address_type": address_type},
|
||||
)
|
||||
|
||||
@@ -23,14 +23,15 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_customer_api
|
||||
from app.core.database import get_db
|
||||
from app.core.environment import should_use_secure_cookies
|
||||
from app.exceptions import ValidationException, VendorNotFoundException
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.tenancy.exceptions import VendorNotFoundException
|
||||
from app.modules.customers.schemas import CustomerContext
|
||||
from app.modules.customers.services import (
|
||||
customer_address_service,
|
||||
customer_service,
|
||||
)
|
||||
from app.services.auth_service import AuthService # noqa: MOD-004 - Core auth service
|
||||
from app.services.email_service import EmailService # noqa: MOD-004 - Core email service
|
||||
from app.modules.core.services.auth_service import AuthService # noqa: MOD-004 - Core auth service
|
||||
from app.modules.messaging.services.email_service import EmailService # noqa: MOD-004 - Core email service
|
||||
from app.modules.customers.models import PasswordResetToken
|
||||
from models.schema.auth import (
|
||||
LogoutResponse,
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
# Page routes will be added here
|
||||
# TODO: Add HTML page routes for admin/vendor dashboards
|
||||
|
||||
__all__ = []
|
||||
# app/modules/customers/routes/pages/__init__.py
|
||||
"""Customers module page routes."""
|
||||
|
||||
40
app/modules/customers/routes/pages/admin.py
Normal file
40
app/modules/customers/routes/pages/admin.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# app/modules/customers/routes/pages/admin.py
|
||||
"""
|
||||
Customers Admin Page Routes (HTML rendering).
|
||||
|
||||
Admin pages for customer management:
|
||||
- Customers list
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER MANAGEMENT ROUTES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/customers", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_customers_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("customers", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render customers management page.
|
||||
Shows list of all platform customers.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"customers/admin/customers.html",
|
||||
get_admin_context(request, current_user),
|
||||
)
|
||||
276
app/modules/customers/routes/pages/storefront.py
Normal file
276
app/modules/customers/routes/pages/storefront.py
Normal file
@@ -0,0 +1,276 @@
|
||||
# app/modules/customers/routes/pages/storefront.py
|
||||
"""
|
||||
Customers Storefront Page Routes (HTML rendering).
|
||||
|
||||
Storefront (customer shop) pages for customer account:
|
||||
- Registration
|
||||
- Login
|
||||
- Forgot password
|
||||
- Reset password
|
||||
- Account dashboard
|
||||
- Profile management
|
||||
- Addresses
|
||||
- Settings
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_customer_from_cookie_or_header, get_db
|
||||
from app.modules.core.utils.page_context import get_storefront_context
|
||||
from app.modules.customers.models import Customer
|
||||
from app.templates_config import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER ACCOUNT - PUBLIC ROUTES (No Authentication)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/account/register", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_register_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render customer registration page.
|
||||
No authentication required.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_register_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(
|
||||
"customers/storefront/register.html", get_storefront_context(request, db=db)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/account/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_login_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render customer login page.
|
||||
No authentication required.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_login_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(
|
||||
"customers/storefront/login.html", get_storefront_context(request, db=db)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/account/forgot-password", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def shop_forgot_password_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render forgot password page.
|
||||
Allows customers to reset their password.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_forgot_password_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(
|
||||
"customers/storefront/forgot-password.html", get_storefront_context(request, db=db)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/account/reset-password", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def shop_reset_password_page(
|
||||
request: Request, token: str = None, db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Render reset password page.
|
||||
User lands here after clicking the reset link in their email.
|
||||
Token is passed as query parameter.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_reset_password_page REACHED",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"vendor": getattr(request.state, "vendor", "NOT SET"),
|
||||
"context": getattr(request.state, "context_type", "NOT SET"),
|
||||
"has_token": bool(token),
|
||||
},
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"customers/storefront/reset-password.html", get_storefront_context(request, db=db)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER ACCOUNT - ROOT REDIRECT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/account", response_class=RedirectResponse, include_in_schema=False)
|
||||
@router.get("/account/", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def shop_account_root(request: Request):
|
||||
"""
|
||||
Redirect /storefront/account or /storefront/account/ to dashboard.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_account_root REACHED",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"vendor": getattr(request.state, "vendor", "NOT SET"),
|
||||
"context": getattr(request.state, "context_type", "NOT SET"),
|
||||
},
|
||||
)
|
||||
|
||||
# Get base_url from context for proper redirect
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
vendor_context = getattr(request.state, "vendor_context", None)
|
||||
access_method = (
|
||||
vendor_context.get("detection_method", "unknown")
|
||||
if vendor_context
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
base_url = "/"
|
||||
if access_method == "path" and vendor:
|
||||
full_prefix = (
|
||||
vendor_context.get("full_prefix", "/vendor/")
|
||||
if vendor_context
|
||||
else "/vendor/"
|
||||
)
|
||||
base_url = f"{full_prefix}{vendor.subdomain}/"
|
||||
|
||||
return RedirectResponse(url=f"{base_url}storefront/account/dashboard", status_code=302)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER ACCOUNT - AUTHENTICATED ROUTES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/account/dashboard", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def shop_account_dashboard_page(
|
||||
request: Request,
|
||||
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render customer account dashboard.
|
||||
Shows account overview, recent orders, and quick links.
|
||||
Requires customer authentication.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_account_dashboard_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(
|
||||
"customers/storefront/dashboard.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/account/profile", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_profile_page(
|
||||
request: Request,
|
||||
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render customer profile page.
|
||||
Edit personal information and preferences.
|
||||
Requires customer authentication.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_profile_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(
|
||||
"customers/storefront/profile.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/account/addresses", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def shop_addresses_page(
|
||||
request: Request,
|
||||
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render customer addresses management page.
|
||||
Manage shipping and billing addresses.
|
||||
Requires customer authentication.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_addresses_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(
|
||||
"customers/storefront/addresses.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/account/settings", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_settings_page(
|
||||
request: Request,
|
||||
current_customer: Customer = Depends(get_current_customer_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render customer account settings page.
|
||||
Configure notifications, privacy, and preferences.
|
||||
Requires customer authentication.
|
||||
"""
|
||||
logger.debug(
|
||||
"[STOREFRONT] shop_settings_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(
|
||||
"customers/storefront/settings.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
)
|
||||
42
app/modules/customers/routes/pages/vendor.py
Normal file
42
app/modules/customers/routes/pages/vendor.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# app/modules/customers/routes/pages/vendor.py
|
||||
"""
|
||||
Customers Vendor Page Routes (HTML rendering).
|
||||
|
||||
Vendor pages for customer management:
|
||||
- Customers list
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CUSTOMER MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/customers", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_customers_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 customers management page.
|
||||
JavaScript loads customer list via API.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"customers/vendor/customers.html",
|
||||
get_vendor_context(request, db, current_user, vendor_code),
|
||||
)
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.customer import CustomerNotFoundException
|
||||
from app.modules.customers.exceptions import CustomerNotFoundException
|
||||
from app.modules.customers.models import Customer
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
from app.modules.customers.exceptions import (
|
||||
AddressLimitExceededException,
|
||||
AddressNotFoundException,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.customer import (
|
||||
from app.modules.customers.exceptions import (
|
||||
CustomerNotActiveException,
|
||||
CustomerNotFoundException,
|
||||
CustomerValidationException,
|
||||
@@ -22,8 +22,8 @@ from app.exceptions.customer import (
|
||||
InvalidPasswordResetTokenException,
|
||||
PasswordTooShortException,
|
||||
)
|
||||
from app.exceptions.vendor import VendorNotActiveException, VendorNotFoundException
|
||||
from app.services.auth_service import AuthService
|
||||
from app.modules.tenancy.exceptions import VendorNotActiveException, VendorNotFoundException
|
||||
from app.modules.core.services.auth_service import AuthService
|
||||
from app.modules.customers.models import Customer, PasswordResetToken
|
||||
from app.modules.customers.schemas import CustomerRegister, CustomerUpdate
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
319
app/modules/customers/templates/customers/admin/customers.html
Normal file
319
app/modules/customers/templates/customers/admin/customers.html
Normal file
@@ -0,0 +1,319 @@
|
||||
{# app/templates/admin/customers.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% 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, table_header %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
{% block title %}Customers{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminCustomers(){% 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='Customer Management', subtitle='Manage customers across all vendors') %}
|
||||
<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='resetAndLoad()', variant='secondary') }}
|
||||
</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 customers...') }}
|
||||
|
||||
{{ error_state('Error loading customers') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Customers -->
|
||||
<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('user-group', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Customers
|
||||
</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 Customers -->
|
||||
<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('user-check', '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: With Orders -->
|
||||
<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('shopping-bag', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
With Orders
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.with_orders || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Total Spent -->
|
||||
<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('currency-euro', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Revenue
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatCurrency(stats.total_spent || 0)">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div x-show="!loading" class="mb-6 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<!-- Search Bar -->
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input.debounce.300ms="resetAndLoad()"
|
||||
placeholder="Search by name, email, or customer number..."
|
||||
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 focus:shadow-outline-purple dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center gap-4">
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@change="resetAndLoad()"
|
||||
class="px-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"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customers Table -->
|
||||
<div x-show="!loading" class="w-full 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">Customer</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Customer #</th>
|
||||
<th class="px-4 py-3">Orders</th>
|
||||
<th class="px-4 py-3">Total Spent</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Joined</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">
|
||||
<!-- Loading state -->
|
||||
<template x-if="loadingCustomers && customers.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||
<p>Loading customers...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!loadingCustomers && customers.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('user-group', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No customers found</p>
|
||||
<p class="text-sm mt-1">Try adjusting your search or filters</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Customer rows -->
|
||||
<template x-for="customer in customers" :key="customer.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- Customer -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span x-html="$icon('user', 'w-4 h-4 text-gray-500')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="customer.first_name && customer.last_name ? customer.first_name + ' ' + customer.last_name : customer.email"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="customer.email"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Vendor -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="customer.vendor_code || customer.vendor_name || '-'"></span>
|
||||
</td>
|
||||
|
||||
<!-- Customer Number -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="font-mono text-xs" x-text="customer.customer_number"></span>
|
||||
</td>
|
||||
|
||||
<!-- Orders -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="customer.total_orders || 0"></span>
|
||||
</td>
|
||||
|
||||
<!-- Total Spent -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatCurrency(customer.total_spent || 0)"></span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="customer.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="customer.is_active ? 'Active' : 'Inactive'"
|
||||
></span>
|
||||
</td>
|
||||
|
||||
<!-- Joined -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatDate(customer.created_at)"></span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
@click="toggleStatus(customer)"
|
||||
class="px-2 py-1 text-xs font-medium leading-5 rounded-lg"
|
||||
:class="customer.is_active
|
||||
? 'text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-red-700/50'
|
||||
: 'text-green-600 hover:bg-green-100 dark:text-green-400 dark:hover:bg-green-700/50'"
|
||||
:title="customer.is_active ? 'Deactivate' : 'Activate'"
|
||||
>
|
||||
<span x-html="customer.is_active ? $icon('x-circle', 'w-4 h-4') : $icon('check-circle', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
{% 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('customers_static', path='admin/js/customers.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,549 @@
|
||||
{# app/templates/storefront/account/addresses.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}My Addresses - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}addressesPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Addresses</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your shipping and billing addresses</p>
|
||||
</div>
|
||||
<button @click="openAddModal()"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span class="-ml-1 mr-2 h-5 w-5" x-html="$icon('plus', 'h-5 w-5')"></span>
|
||||
Add Address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('spinner', 'h-8 w-8 animate-spin')"></span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<span class="h-5 w-5 text-red-400" x-html="$icon('exclamation-circle', 'h-5 w-5')"></span>
|
||||
<p class="ml-3 text-sm text-red-700 dark:text-red-400" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && !error && addresses.length === 0"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
|
||||
<button @click="openAddModal()"
|
||||
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark"
|
||||
style="background-color: var(--color-primary)">
|
||||
Add Your First Address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Addresses Grid -->
|
||||
<div x-show="!loading && addresses.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<template x-for="address in addresses" :key="address.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 relative">
|
||||
<!-- Default Badge -->
|
||||
<div x-show="address.is_default" class="absolute top-4 right-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="address.address_type === 'shipping' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'">
|
||||
<span class="-ml-0.5 mr-1 h-3 w-3" x-html="$icon('check-circle', 'h-3 w-3')"></span>
|
||||
Default <span x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'" class="ml-1"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Address Type Badge (non-default) -->
|
||||
<div x-show="!address.is_default" class="absolute top-4 right-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'"></span>
|
||||
</div>
|
||||
|
||||
<!-- Address Content -->
|
||||
<div class="pr-24">
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-white" x-text="address.first_name + ' ' + address.last_name"></p>
|
||||
<p x-show="address.company" class="text-sm text-gray-600 dark:text-gray-400" x-text="address.company"></p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400" x-text="address.address_line_1"></p>
|
||||
<p x-show="address.address_line_2" class="text-sm text-gray-600 dark:text-gray-400" x-text="address.address_line_2"></p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="address.postal_code + ' ' + address.city"></p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="address.country_name"></p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center space-x-4">
|
||||
<button @click="openEditModal(address)"
|
||||
class="text-sm font-medium text-primary hover:text-primary-dark"
|
||||
style="color: var(--color-primary)">
|
||||
Edit
|
||||
</button>
|
||||
<button x-show="!address.is_default"
|
||||
@click="setAsDefault(address.id)"
|
||||
class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
Set as Default
|
||||
</button>
|
||||
<button @click="openDeleteModal(address.id)"
|
||||
class="text-sm font-medium text-red-600 hover:text-red-700">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Address Modal -->
|
||||
{# noqa: FE-004 - Complex form modal with dynamic title and extensive form fields not suited for form_modal macro #}
|
||||
<div x-show="showAddressModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<!-- Overlay -->
|
||||
<div x-show="showAddressModal"
|
||||
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"
|
||||
@click="showAddressModal = false"
|
||||
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||
|
||||
<!-- Modal Panel -->
|
||||
<div x-show="showAddressModal"
|
||||
@click.stop
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
|
||||
<div class="absolute top-0 right-0 pt-4 pr-4">
|
||||
<button @click="showAddressModal = false" class="text-gray-400 hover:text-gray-500">
|
||||
<span class="h-6 w-6" x-html="$icon('x-mark', 'h-6 w-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="w-full">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-6"
|
||||
x-text="editingAddress ? 'Edit Address' : 'Add New Address'"></h3>
|
||||
|
||||
<form @submit.prevent="saveAddress()" class="space-y-4">
|
||||
<!-- Address Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Type</label>
|
||||
<select x-model="addressForm.address_type"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
<option value="shipping">Shipping Address</option>
|
||||
<option value="billing">Billing Address</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Name Row -->
|
||||
<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 *</label>
|
||||
<input type="text" x-model="addressForm.first_name" required
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
</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="addressForm.last_name" required
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company (optional)</label>
|
||||
<input type="text" x-model="addressForm.company"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Address Line 1 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
|
||||
<input type="text" x-model="addressForm.address_line_1" required
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Address Line 2 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2 (optional)</label>
|
||||
<input type="text" x-model="addressForm.address_line_2"
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- City & Postal Code -->
|
||||
<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">Postal Code *</label>
|
||||
<input type="text" x-model="addressForm.postal_code" required
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
</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="addressForm.city" required
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Country -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
|
||||
<select x-model="addressForm.country_iso"
|
||||
@change="addressForm.country_name = countries.find(c => c.iso === addressForm.country_iso)?.name || ''"
|
||||
required
|
||||
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
|
||||
<template x-for="country in countries" :key="country.iso">
|
||||
<option :value="country.iso" x-text="country.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Default Checkbox -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" x-model="addressForm.is_default"
|
||||
class="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
style="color: var(--color-primary)">
|
||||
<label class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Set as default <span x-text="addressForm.address_type === 'shipping' ? 'shipping' : 'billing'"></span> address
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="formError" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
|
||||
<p class="text-sm text-red-700 dark:text-red-400" x-text="formError"></p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button type="button" @click="showAddressModal = false"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark disabled:opacity-50"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="!saving" x-text="editingAddress ? 'Save Changes' : 'Add Address'"></span>
|
||||
<span x-show="saving">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div x-show="showDeleteModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<!-- Overlay -->
|
||||
<div x-show="showDeleteModal"
|
||||
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"
|
||||
@click="showDeleteModal = false"
|
||||
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||
|
||||
<!-- Modal Panel -->
|
||||
<div x-show="showDeleteModal"
|
||||
@click.stop
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<span class="h-6 w-6 text-red-600 dark:text-red-400" x-html="$icon('exclamation-triangle', 'h-6 w-6')"></span>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">Delete Address</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to delete this address? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button @click="confirmDelete()"
|
||||
:disabled="deleting"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50">
|
||||
<span x-show="!deleting">Delete</span>
|
||||
<span x-show="deleting">Deleting...</span>
|
||||
</button>
|
||||
<button @click="showDeleteModal = false"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function addressesPage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
loading: true,
|
||||
error: '',
|
||||
addresses: [],
|
||||
|
||||
// Modal state
|
||||
showAddressModal: false,
|
||||
showDeleteModal: false,
|
||||
editingAddress: null,
|
||||
deletingAddressId: null,
|
||||
saving: false,
|
||||
deleting: false,
|
||||
formError: '',
|
||||
|
||||
// Form data
|
||||
addressForm: {
|
||||
address_type: 'shipping',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_line_1: '',
|
||||
address_line_2: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country_name: 'Luxembourg',
|
||||
country_iso: 'LU',
|
||||
is_default: false
|
||||
},
|
||||
|
||||
// Countries list
|
||||
countries: [
|
||||
{ iso: 'LU', name: 'Luxembourg' },
|
||||
{ iso: 'DE', name: 'Germany' },
|
||||
{ iso: 'FR', name: 'France' },
|
||||
{ iso: 'BE', name: 'Belgium' },
|
||||
{ iso: 'NL', name: 'Netherlands' },
|
||||
{ iso: 'AT', name: 'Austria' },
|
||||
{ iso: 'IT', name: 'Italy' },
|
||||
{ iso: 'ES', name: 'Spain' },
|
||||
{ iso: 'PT', name: 'Portugal' },
|
||||
{ iso: 'PL', name: 'Poland' },
|
||||
{ iso: 'CZ', name: 'Czech Republic' },
|
||||
{ iso: 'SK', name: 'Slovakia' },
|
||||
{ iso: 'HU', name: 'Hungary' },
|
||||
{ iso: 'RO', name: 'Romania' },
|
||||
{ iso: 'BG', name: 'Bulgaria' },
|
||||
{ iso: 'GR', name: 'Greece' },
|
||||
{ iso: 'HR', name: 'Croatia' },
|
||||
{ iso: 'SI', name: 'Slovenia' },
|
||||
{ iso: 'EE', name: 'Estonia' },
|
||||
{ iso: 'LV', name: 'Latvia' },
|
||||
{ iso: 'LT', name: 'Lithuania' },
|
||||
{ iso: 'FI', name: 'Finland' },
|
||||
{ iso: 'SE', name: 'Sweden' },
|
||||
{ iso: 'DK', name: 'Denmark' },
|
||||
{ iso: 'IE', name: 'Ireland' },
|
||||
{ iso: 'CY', name: 'Cyprus' },
|
||||
{ iso: 'MT', name: 'Malta' },
|
||||
{ iso: 'GB', name: 'United Kingdom' },
|
||||
{ iso: 'CH', name: 'Switzerland' },
|
||||
],
|
||||
|
||||
async init() {
|
||||
await this.loadAddresses();
|
||||
},
|
||||
|
||||
async loadAddresses() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
const response = await fetch('/api/v1/shop/addresses', {
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load addresses');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.addresses = data.addresses;
|
||||
} catch (err) {
|
||||
console.error('[ADDRESSES] Error loading:', err);
|
||||
this.error = 'Failed to load addresses. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
openAddModal() {
|
||||
this.editingAddress = null;
|
||||
this.formError = '';
|
||||
this.addressForm = {
|
||||
address_type: 'shipping',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_line_1: '',
|
||||
address_line_2: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country_name: 'Luxembourg',
|
||||
country_iso: 'LU',
|
||||
is_default: false
|
||||
};
|
||||
this.showAddressModal = true;
|
||||
},
|
||||
|
||||
openEditModal(address) {
|
||||
this.editingAddress = address;
|
||||
this.formError = '';
|
||||
this.addressForm = {
|
||||
address_type: address.address_type,
|
||||
first_name: address.first_name,
|
||||
last_name: address.last_name,
|
||||
company: address.company || '',
|
||||
address_line_1: address.address_line_1,
|
||||
address_line_2: address.address_line_2 || '',
|
||||
city: address.city,
|
||||
postal_code: address.postal_code,
|
||||
country_name: address.country_name,
|
||||
country_iso: address.country_iso,
|
||||
is_default: address.is_default
|
||||
};
|
||||
this.showAddressModal = true;
|
||||
},
|
||||
|
||||
async saveAddress() {
|
||||
this.saving = true;
|
||||
this.formError = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
const url = this.editingAddress
|
||||
? `/api/v1/shop/addresses/${this.editingAddress.id}`
|
||||
: '/api/v1/shop/addresses';
|
||||
const method = this.editingAddress ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify(this.addressForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || data.message || 'Failed to save address');
|
||||
}
|
||||
|
||||
this.showAddressModal = false;
|
||||
this.showToast(this.editingAddress ? 'Address updated' : 'Address added', 'success');
|
||||
await this.loadAddresses();
|
||||
} catch (err) {
|
||||
console.error('[ADDRESSES] Error saving:', err);
|
||||
this.formError = err.message || 'Failed to save address. Please try again.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
openDeleteModal(addressId) {
|
||||
this.deletingAddressId = addressId;
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
|
||||
async confirmDelete() {
|
||||
this.deleting = true;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
const response = await fetch(`/api/v1/shop/addresses/${this.deletingAddressId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete address');
|
||||
}
|
||||
|
||||
this.showDeleteModal = false;
|
||||
this.showToast('Address deleted', 'success');
|
||||
await this.loadAddresses();
|
||||
} catch (err) {
|
||||
console.error('[ADDRESSES] Error deleting:', err);
|
||||
this.showToast('Failed to delete address', 'error');
|
||||
} finally {
|
||||
this.deleting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async setAsDefault(addressId) {
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
const response = await fetch(`/api/v1/shop/addresses/${addressId}/default`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to set default address');
|
||||
}
|
||||
|
||||
this.showToast('Default address updated', 'success');
|
||||
await this.loadAddresses();
|
||||
} catch (err) {
|
||||
console.error('[ADDRESSES] Error setting default:', err);
|
||||
this.showToast('Failed to set default address', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,183 @@
|
||||
{# app/templates/storefront/account/dashboard.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
{% from 'shared/macros/modals.html' import confirm_modal %}
|
||||
|
||||
{% block title %}My Account - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}accountDashboard(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Account</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Welcome back, {{ user.first_name }}!</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Orders Card -->
|
||||
<a href="{{ base_url }}shop/account/orders"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Profile Card -->
|
||||
<a href="{{ base_url }}shop/account/profile"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('user', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Profile</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Edit your information</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ user.email }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Addresses Card -->
|
||||
<a href="{{ base_url }}shop/account/addresses"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Manage addresses</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Messages Card -->
|
||||
<a href="{{ base_url }}shop/account/messages"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ unreadCount: 0 }"
|
||||
x-init="fetch('/api/v1/shop/messages/unread-count').then(r => r.json()).then(d => unreadCount = d.unread_count).catch(() => {})">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0 relative">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('chat-bubble-left', 'h-8 w-8')"></span>
|
||||
<span x-show="unreadCount > 0"
|
||||
class="absolute -top-1 -right-1 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white bg-red-600 rounded-full"
|
||||
x-text="unreadCount > 9 ? '9+' : unreadCount"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Messages</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Contact support</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="unreadCount > 0">
|
||||
<p class="text-sm text-primary font-medium" style="color: var(--color-primary)" x-text="unreadCount + ' unread message' + (unreadCount > 1 ? 's' : '')"></p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Account Summary -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Account Summary</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8 flex justify-end">
|
||||
<button @click="showLogoutModal = true"
|
||||
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Confirmation Modal -->
|
||||
{{ confirm_modal(
|
||||
id='logoutModal',
|
||||
title='Logout Confirmation',
|
||||
message="Are you sure you want to logout? You'll need to sign in again to access your account.",
|
||||
confirm_action='confirmLogout()',
|
||||
show_var='showLogoutModal',
|
||||
confirm_text='Logout',
|
||||
cancel_text='Cancel',
|
||||
variant='danger'
|
||||
) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function accountDashboard() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
showLogoutModal: false,
|
||||
|
||||
confirmLogout() {
|
||||
// Close modal
|
||||
this.showLogoutModal = false;
|
||||
|
||||
fetch('/api/v1/shop/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
// Clear localStorage token if any
|
||||
localStorage.removeItem('customer_token');
|
||||
|
||||
// Show success message
|
||||
this.showToast('Logged out successfully', 'success');
|
||||
|
||||
// Redirect to login page
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
}, 500);
|
||||
} else {
|
||||
console.error('Logout failed with status:', response.status);
|
||||
this.showToast('Logout failed', 'error');
|
||||
// Still redirect on failure (cookie might be deleted)
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Logout error:', error);
|
||||
this.showToast('Logout failed', 'error');
|
||||
// Redirect anyway
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,251 @@
|
||||
{# app/templates/storefront/account/forgot-password.html #}
|
||||
{# standalone #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Forgot Password - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🔐</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Reset your password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Forgot Password Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<!-- Initial Form State -->
|
||||
<template x-if="!emailSent">
|
||||
<div>
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Forgot Password
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
|
||||
<input x-model="email"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="email"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Send Reset Link</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Sending...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success State -->
|
||||
<template x-if="emailSent">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
|
||||
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Check Your Email
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
We've sent a password reset link to <strong x-text="email"></strong>.
|
||||
Please check your inbox and click the link to reset your password.
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Didn't receive the email? Check your spam folder or
|
||||
<button @click="emailSent = false"
|
||||
class="font-medium hover:underline"
|
||||
style="color: var(--color-primary);">
|
||||
try again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/login">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="{{ base_url }}shop/">
|
||||
← Continue shopping
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Forgot Password Logic -->
|
||||
<script>
|
||||
function forgotPassword() {
|
||||
return {
|
||||
// Data
|
||||
email: '',
|
||||
emailSent: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle form submission
|
||||
async handleSubmit() {
|
||||
this.clearErrors();
|
||||
|
||||
// Basic validation
|
||||
if (!this.email) {
|
||||
this.errors.email = 'Email is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email)) {
|
||||
this.errors.email = 'Please enter a valid email address';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: this.email
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Failed to send reset link');
|
||||
}
|
||||
|
||||
// Success - show email sent message
|
||||
this.emailSent = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error);
|
||||
this.showAlert(error.message || 'Failed to send reset link. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
281
app/modules/customers/templates/customers/storefront/login.html
Normal file
281
app/modules/customers/templates/customers/storefront/login.html
Normal file
@@ -0,0 +1,281 @@
|
||||
{# app/templates/storefront/account/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="customerLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Customer Login - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🛒</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Welcome back to your shopping experience</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Login Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Customer Login
|
||||
</h1>
|
||||
|
||||
<!-- Success Message (after registration) -->
|
||||
<div x-show="alert.show && alert.type === 'success'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
|
||||
<input x-model="credentials.email"
|
||||
:disabled="loading"
|
||||
@input="clearAllErrors"
|
||||
type="email"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<div class="relative">
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearAllErrors"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPassword ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center text-sm">
|
||||
<input type="checkbox"
|
||||
x-model="rememberMe"
|
||||
class="form-checkbox focus-primary focus:outline-none"
|
||||
style="color: var(--color-primary);">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-400">Remember me</span>
|
||||
</label>
|
||||
<a href="{{ base_url }}shop/account/forgot-password"
|
||||
class="text-sm font-medium hover:underline"
|
||||
style="color: var(--color-primary);">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Sign in</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Signing in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Don't have an account?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/register">
|
||||
Create an account
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="{{ base_url }}shop/">
|
||||
← Continue shopping
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Login Logic -->
|
||||
<script>
|
||||
function customerLogin() {
|
||||
return {
|
||||
// Data
|
||||
credentials: {
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
rememberMe: false,
|
||||
showPassword: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
this.checkRegistrationSuccess();
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
},
|
||||
|
||||
// Check if redirected after registration
|
||||
checkRegistrationSuccess() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('registered') === 'true') {
|
||||
this.showAlert('Account created successfully! Please sign in.', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearAllErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle login
|
||||
async handleLogin() {
|
||||
this.clearAllErrors();
|
||||
|
||||
// Basic validation
|
||||
if (!this.credentials.email) {
|
||||
this.errors.email = 'Email is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.credentials.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email_or_username: this.credentials.email,
|
||||
password: this.credentials.password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Login failed');
|
||||
}
|
||||
|
||||
// Store token and user data
|
||||
localStorage.setItem('customer_token', data.access_token);
|
||||
localStorage.setItem('customer_user', JSON.stringify(data.user));
|
||||
|
||||
this.showAlert('Login successful! Redirecting...', 'success');
|
||||
|
||||
// Redirect to account page or return URL
|
||||
setTimeout(() => {
|
||||
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}shop/account';
|
||||
window.location.href = returnUrl;
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
this.showAlert(error.message || 'Invalid email or password');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,524 @@
|
||||
{# app/templates/storefront/account/profile.html #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}My Profile - {{ vendor.name }}{% endblock %}
|
||||
|
||||
{% block alpine_data %}shopProfilePage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<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/account/dashboard" class="hover:text-primary">My Account</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">Profile</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Profile</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your account information and preferences</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<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>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<span class="h-5 w-5 text-green-400" x-html="$icon('check-circle', 'h-5 w-5')"></span>
|
||||
<p class="ml-3 text-sm text-green-700 dark:text-green-300" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading" class="space-y-8">
|
||||
<!-- Profile Information Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your personal details</p>
|
||||
</div>
|
||||
<form @submit.prevent="saveProfile" class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<!-- First Name -->
|
||||
<div>
|
||||
<label for="first_name" 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" id="first_name" x-model="profileForm.first_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Last Name -->
|
||||
<div>
|
||||
<label for="last_name" 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" id="last_name" x-model="profileForm.last_name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" id="email" x-model="profileForm.email" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone Number
|
||||
</label>
|
||||
<input type="tel" id="phone" x-model="profileForm.phone"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="savingProfile"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm
|
||||
text-sm font-medium text-white bg-primary hover:bg-primary-dark
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="savingProfile" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="savingProfile ? 'Saving...' : 'Save Changes'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Preferences</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your account preferences</p>
|
||||
</div>
|
||||
<form @submit.prevent="savePreferences" class="p-6 space-y-6">
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preferred Language
|
||||
</label>
|
||||
<select id="language" x-model="preferencesForm.preferred_language"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<option value="">Use shop default</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Francais</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="lb">Letzebuergesch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Consent -->
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input type="checkbox" id="marketing_consent" x-model="preferencesForm.marketing_consent"
|
||||
class="h-4 w-4 rounded border-gray-300 dark:border-gray-600
|
||||
focus:ring-2 focus:ring-primary
|
||||
dark:bg-gray-700"
|
||||
style="color: var(--color-primary)">
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<label for="marketing_consent" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Marketing Communications
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Receive emails about new products, offers, and promotions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="savingPreferences"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm
|
||||
text-sm font-medium text-white bg-primary hover:bg-primary-dark
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="savingPreferences" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="savingPreferences ? 'Saving...' : 'Save Preferences'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Change Password</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your account password</p>
|
||||
</div>
|
||||
<form @submit.prevent="changePassword" class="p-6 space-y-6">
|
||||
<!-- Current Password -->
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Current Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="current_password" x-model="passwordForm.current_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="new_password" x-model="passwordForm.new_password" required
|
||||
minlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 8 characters with at least one letter and one number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm New Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
dark:bg-gray-700 dark:text-white"
|
||||
style="--tw-ring-color: var(--color-primary)">
|
||||
<p x-show="passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="mt-1 text-xs text-red-500">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Error -->
|
||||
<div x-show="passwordError" class="text-sm text-red-600 dark:text-red-400" x-text="passwordError"></div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
:disabled="changingPassword || passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm
|
||||
text-sm font-medium text-white bg-primary hover:bg-primary-dark
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style="background-color: var(--color-primary)">
|
||||
<span x-show="changingPassword" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
<span x-text="changingPassword ? 'Changing...' : 'Change Password'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (read-only) -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Account Information</h3>
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Customer Number</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.customer_number || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Member Since</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatDate(profile?.created_at)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Total Orders</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.total_orders || 0"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Total Spent</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatPrice(profile?.total_spent)"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function shopProfilePage() {
|
||||
return {
|
||||
...shopLayoutData(),
|
||||
|
||||
// State
|
||||
profile: null,
|
||||
loading: true,
|
||||
error: '',
|
||||
successMessage: '',
|
||||
|
||||
// Forms
|
||||
profileForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
},
|
||||
preferencesForm: {
|
||||
preferred_language: '',
|
||||
marketing_consent: false
|
||||
},
|
||||
passwordForm: {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
},
|
||||
|
||||
// Form states
|
||||
savingProfile: false,
|
||||
savingPreferences: false,
|
||||
changingPassword: false,
|
||||
passwordError: '',
|
||||
|
||||
async init() {
|
||||
await this.loadProfile();
|
||||
},
|
||||
|
||||
async loadProfile() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('customer_token');
|
||||
localStorage.removeItem('customer_user');
|
||||
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
|
||||
// Populate forms
|
||||
this.profileForm = {
|
||||
first_name: this.profile.first_name || '',
|
||||
last_name: this.profile.last_name || '',
|
||||
email: this.profile.email || '',
|
||||
phone: this.profile.phone || ''
|
||||
};
|
||||
this.preferencesForm = {
|
||||
preferred_language: this.profile.preferred_language || '',
|
||||
marketing_consent: this.profile.marketing_consent || false
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading profile:', err);
|
||||
this.error = err.message || 'Failed to load profile';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.savingProfile = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.profileForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to save profile');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
this.successMessage = 'Profile updated successfully';
|
||||
|
||||
// Update localStorage user data
|
||||
const userStr = localStorage.getItem('customer_user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
user.first_name = this.profile.first_name;
|
||||
user.last_name = this.profile.last_name;
|
||||
user.email = this.profile.email;
|
||||
localStorage.setItem('customer_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving profile:', err);
|
||||
this.error = err.message || 'Failed to save profile';
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async savePreferences() {
|
||||
this.savingPreferences = true;
|
||||
this.error = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.preferencesForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to save preferences');
|
||||
}
|
||||
|
||||
this.profile = await response.json();
|
||||
this.successMessage = 'Preferences updated successfully';
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving preferences:', err);
|
||||
this.error = err.message || 'Failed to save preferences';
|
||||
} finally {
|
||||
this.savingPreferences = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
this.passwordError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.changingPassword = true;
|
||||
this.passwordError = '';
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('customer_token');
|
||||
if (!token) {
|
||||
window.location.href = '{{ base_url }}shop/account/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/shop/profile/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.passwordForm)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to change password');
|
||||
}
|
||||
|
||||
// Clear password form
|
||||
this.passwordForm = {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
};
|
||||
|
||||
this.successMessage = 'Password changed successfully';
|
||||
setTimeout(() => this.successMessage = '', 5000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error changing password:', err);
|
||||
this.passwordError = err.message || 'Failed to change password';
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
// formatPrice is inherited from shopLayoutData() via spread operator
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,377 @@
|
||||
{# app/templates/storefront/account/register.html #}
|
||||
{# standalone #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="customerRegistration()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create Account - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🛒</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Join our community today</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Registration Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Create Account
|
||||
</h1>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="alert.show && alert.type === 'success'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<form @submit.prevent="handleRegister">
|
||||
<!-- First Name -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
First Name <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="formData.first_name"
|
||||
:disabled="loading"
|
||||
@input="clearError('first_name')"
|
||||
type="text"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.first_name }"
|
||||
placeholder="Enter your first name"
|
||||
required />
|
||||
<span x-show="errors.first_name" x-text="errors.first_name"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Last Name -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Last Name <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="formData.last_name"
|
||||
:disabled="loading"
|
||||
@input="clearError('last_name')"
|
||||
type="text"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.last_name }"
|
||||
placeholder="Enter your last name"
|
||||
required />
|
||||
<span x-show="errors.last_name" x-text="errors.last_name"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Email -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Email Address <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="formData.email"
|
||||
:disabled="loading"
|
||||
@input="clearError('email')"
|
||||
type="email"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.email }"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
required />
|
||||
<span x-show="errors.email" x-text="errors.email"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Phone (Optional) -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Phone Number</span>
|
||||
<input x-model="formData.phone"
|
||||
:disabled="loading"
|
||||
type="tel"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
placeholder="+352 123 456 789" />
|
||||
</label>
|
||||
|
||||
<!-- Password -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Password <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<div class="relative">
|
||||
<input x-model="formData.password"
|
||||
:disabled="loading"
|
||||
@input="clearError('password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="At least 8 characters"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<button type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-text="showPassword ? '👁️' : '👁️🗨️'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must contain at least 8 characters, one letter, and one number
|
||||
</p>
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
Confirm Password <span class="text-red-600">*</span>
|
||||
</span>
|
||||
<input x-model="confirmPassword"
|
||||
:disabled="loading"
|
||||
@input="clearError('confirmPassword')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.confirmPassword }"
|
||||
placeholder="Re-enter your password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.confirmPassword" x-text="errors.confirmPassword"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<!-- Marketing Consent -->
|
||||
<div class="flex items-start mt-4">
|
||||
<input type="checkbox"
|
||||
x-model="formData.marketing_consent"
|
||||
id="marketingConsent"
|
||||
class="form-checkbox focus-primary focus:outline-none mt-1"
|
||||
style="color: var(--color-primary);">
|
||||
<label for="marketingConsent" class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
I'd like to receive news and special offers
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Create Account</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Creating account...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Already have an account?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/login">
|
||||
Sign in instead
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Registration Logic -->
|
||||
<script>
|
||||
function customerRegistration() {
|
||||
return {
|
||||
// Data
|
||||
formData: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
marketing_consent: false
|
||||
},
|
||||
confirmPassword: '',
|
||||
showPassword: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
},
|
||||
|
||||
// Clear specific error
|
||||
clearError(field) {
|
||||
delete this.errors[field];
|
||||
},
|
||||
|
||||
// Clear all errors
|
||||
clearAllErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Validate form
|
||||
validateForm() {
|
||||
this.clearAllErrors();
|
||||
let isValid = true;
|
||||
|
||||
// First name
|
||||
if (!this.formData.first_name.trim()) {
|
||||
this.errors.first_name = 'First name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Last name
|
||||
if (!this.formData.last_name.trim()) {
|
||||
this.errors.last_name = 'Last name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Email
|
||||
if (!this.formData.email.trim()) {
|
||||
this.errors.email = 'Email is required';
|
||||
isValid = false;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)) {
|
||||
this.errors.email = 'Please enter a valid email address';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Password
|
||||
if (!this.formData.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (this.formData.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters';
|
||||
isValid = false;
|
||||
} else if (!/[a-zA-Z]/.test(this.formData.password)) {
|
||||
this.errors.password = 'Password must contain at least one letter';
|
||||
isValid = false;
|
||||
} else if (!/[0-9]/.test(this.formData.password)) {
|
||||
this.errors.password = 'Password must contain at least one number';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Confirm password
|
||||
if (this.formData.password !== this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
},
|
||||
|
||||
// Handle registration
|
||||
async handleRegister() {
|
||||
if (!this.validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Registration failed');
|
||||
}
|
||||
|
||||
// Success!
|
||||
this.showAlert('Account created successfully! Redirecting to login...', 'success');
|
||||
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/account/login?registered=true';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.showAlert(error.message || 'Registration failed. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,314 @@
|
||||
{# app/templates/storefront/account/reset-password.html #}
|
||||
{# standalone #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'dark': dark }" x-data="resetPassword()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reset Password - {{ vendor.name }}</title>
|
||||
<!-- Fonts: Local fallback + Google Fonts -->
|
||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
{# CRITICAL: Inject theme CSS variables #}
|
||||
<style id="vendor-theme-variables">
|
||||
:root {
|
||||
{% for key, value in theme.css_variables.items() %}
|
||||
{{ key }}: {{ value }};
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{# Custom CSS from vendor theme #}
|
||||
{% if theme.custom_css %}
|
||||
{{ theme.custom_css | safe }}{# sanitized: admin-controlled #}
|
||||
{% endif %}
|
||||
|
||||
/* Theme-aware button and focus colors */
|
||||
.btn-primary-theme {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary-theme:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark, var(--color-primary));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.focus-primary:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 124, 58, 237), 0.1);
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{# Tailwind CSS v4 (built locally via standalone CLI) #}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<!-- Left side - Image/Branding with Theme Colors -->
|
||||
<div class="h-32 md:h-auto md:w-1/2 flex items-center justify-center"
|
||||
style="background-color: var(--color-primary);">
|
||||
<div class="text-center p-8">
|
||||
{% if theme.branding.logo %}
|
||||
<img src="{{ theme.branding.logo }}"
|
||||
alt="{{ vendor.name }}"
|
||||
class="mx-auto mb-4 max-w-xs max-h-32 object-contain" />
|
||||
{% else %}
|
||||
<div class="text-6xl mb-4">🔑</div>
|
||||
{% endif %}
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ vendor.name }}</h2>
|
||||
<p class="text-white opacity-90">Create new password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Reset Password Form -->
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<!-- Invalid Token State -->
|
||||
<template x-if="tokenInvalid">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900">
|
||||
<span class="w-8 h-8 text-red-600 dark:text-red-400" x-html="$icon('x-mark', 'w-8 h-8')"></span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Invalid or Expired Link
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
This password reset link is invalid or has expired.
|
||||
Please request a new password reset link.
|
||||
</p>
|
||||
|
||||
<a href="{{ base_url }}shop/account/forgot-password"
|
||||
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
|
||||
Request New Link
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reset Form State -->
|
||||
<template x-if="!tokenInvalid && !resetComplete">
|
||||
<div>
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Reset Your Password
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your new password below. Password must be at least 8 characters.
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="alert.show && alert.type === 'error'"
|
||||
x-text="alert.message"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Reset Password Form -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">New Password</span>
|
||||
<input x-model="password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="password"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="Enter new password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Confirm Password</span>
|
||||
<input x-model="confirmPassword"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
type="password"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
|
||||
:class="{ 'border-red-600': errors.confirmPassword }"
|
||||
placeholder="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
required />
|
||||
<span x-show="errors.confirmPassword" x-text="errors.confirmPassword"
|
||||
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Reset Password</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
|
||||
Resetting...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success State -->
|
||||
<template x-if="resetComplete">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
|
||||
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Password Reset Complete
|
||||
</h1>
|
||||
|
||||
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
||||
Your password has been successfully reset.
|
||||
You can now sign in with your new password.
|
||||
</p>
|
||||
|
||||
<a href="{{ base_url }}shop/account/login"
|
||||
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
|
||||
Sign In
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
|
||||
<a class="text-sm font-medium hover:underline ml-1"
|
||||
style="color: var(--color-primary);"
|
||||
href="{{ base_url }}shop/account/login">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-center">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="{{ base_url }}shop/">
|
||||
← Continue shopping
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Reset Password Logic -->
|
||||
<script>
|
||||
function resetPassword() {
|
||||
return {
|
||||
// Data
|
||||
token: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
tokenInvalid: false,
|
||||
resetComplete: false,
|
||||
loading: false,
|
||||
errors: {},
|
||||
alert: {
|
||||
show: false,
|
||||
type: 'error',
|
||||
message: ''
|
||||
},
|
||||
dark: false,
|
||||
|
||||
// Initialize
|
||||
init() {
|
||||
// Check for dark mode preference
|
||||
this.dark = localStorage.getItem('darkMode') === 'true';
|
||||
|
||||
// Get token from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.token = urlParams.get('token');
|
||||
|
||||
if (!this.token) {
|
||||
this.tokenInvalid = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearErrors() {
|
||||
this.errors = {};
|
||||
this.alert.show = false;
|
||||
},
|
||||
|
||||
// Show alert
|
||||
showAlert(message, type = 'error') {
|
||||
this.alert = {
|
||||
show: true,
|
||||
type: type,
|
||||
message: message
|
||||
};
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
|
||||
// Handle form submission
|
||||
async handleSubmit() {
|
||||
this.clearErrors();
|
||||
|
||||
// Validation
|
||||
if (!this.password) {
|
||||
this.errors.password = 'Password is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Please confirm your password';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.password !== this.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reset_token: this.token,
|
||||
new_password: this.password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for token-related errors
|
||||
if (response.status === 400 && data.detail) {
|
||||
if (data.detail.includes('invalid') || data.detail.includes('expired')) {
|
||||
this.tokenInvalid = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(data.detail || 'Failed to reset password');
|
||||
}
|
||||
|
||||
// Success
|
||||
this.resetComplete = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
this.showAlert(error.message || 'Failed to reset password. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
268
app/modules/customers/templates/customers/vendor/customers.html
vendored
Normal file
268
app/modules/customers/templates/customers/vendor/customers.html
vendored
Normal file
@@ -0,0 +1,268 @@
|
||||
{# app/templates/vendor/customers.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 %}Customers{% endblock %}
|
||||
|
||||
{% block alpine_data %}vendorCustomers(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Customers', subtitle='View and manage your customer relationships') %}
|
||||
<div class="flex items-center gap-4">
|
||||
{{ refresh_button(loading_var='loading', onclick='loadCustomers()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading customers...') }}
|
||||
|
||||
{{ error_state('Error loading customers') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-3">
|
||||
<!-- Total Customers -->
|
||||
<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 Customers</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Customers -->
|
||||
<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>
|
||||
|
||||
<!-- New This Month -->
|
||||
<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('user-plus', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">New This Month</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.new_this_month">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 by name or email..."
|
||||
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>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<button
|
||||
x-show="filters.search || filters.status"
|
||||
@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>
|
||||
|
||||
<!-- Customers Table -->
|
||||
<div x-show="!loading && !error" class="mb-8">
|
||||
{% 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">Customer</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Joined</th>
|
||||
<th class="px-4 py-3">Orders</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="customer in customers" :key="customer.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<!-- Customer Info -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative w-10 h-10 mr-3 rounded-full overflow-hidden 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="getInitials(customer)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="`${customer.first_name || ''} ${customer.last_name || ''}`.trim() || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="customer.phone || ''"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Email -->
|
||||
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
|
||||
<!-- Joined -->
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
|
||||
<!-- Orders -->
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="customer.order_count || 0"></td>
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
@click="viewCustomer(customer)"
|
||||
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="viewCustomerOrders(customer)"
|
||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
title="View Orders"
|
||||
>
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="messageCustomer(customer)"
|
||||
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
|
||||
title="Send Message"
|
||||
>
|
||||
<span x-html="$icon('chat-bubble-left-right', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- Empty State -->
|
||||
<tr x-show="customers.length === 0">
|
||||
<td colspan="5" 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('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||
<p class="text-lg font-medium">No customers found</p>
|
||||
<p class="text-sm">Customers will appear here when they make purchases</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
|
||||
<!-- Customer Detail Modal -->
|
||||
{% call modal_simple('customerDetailModal', 'Customer Details', show_var='showDetailModal', size='md') %}
|
||||
<div x-show="selectedCustomer">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-4">
|
||||
<span class="text-xl font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials(selectedCustomer)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim() || 'Unknown'"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedCustomer?.email"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Phone</p>
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.phone || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Joined</p>
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-4 mt-4 border-t dark:border-gray-700">
|
||||
<button @click="showDetailModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
Close
|
||||
</button>
|
||||
<button @click="messageCustomer(selectedCustomer); showDetailModal = false" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
Send Message
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Customer Orders Modal -->
|
||||
<div x-show="showOrdersModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
|
||||
<div class="w-full max-w-2xl bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showOrdersModal = false">
|
||||
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
Orders for <span x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim()"></span>
|
||||
</h3>
|
||||
<button @click="showOrdersModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 max-h-96 overflow-y-auto">
|
||||
<template x-if="customerOrders.length === 0">
|
||||
<p class="text-center text-gray-500 dark:text-gray-400 py-8">No orders found for this customer</p>
|
||||
</template>
|
||||
<template x-for="order in customerOrders" :key="order.id">
|
||||
<div class="flex items-center justify-between p-3 border-b dark:border-gray-700 last:border-0">
|
||||
<div>
|
||||
<p class="font-mono font-semibold text-gray-800 dark:text-gray-200" x-text="order.order_number || `#${order.id}`"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(order.created_at)"></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="formatPrice(order.total)"></p>
|
||||
<span
|
||||
class="text-xs px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'completed',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': order.status === 'pending',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'processing'
|
||||
}"
|
||||
x-text="order.status"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-end p-4 border-t dark:border-gray-700">
|
||||
<button @click="showOrdersModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('customers_static', path='vendor/js/customers.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user