feat: add customer multiple addresses management
- Add CustomerAddressService with CRUD operations - Add shop API endpoints for address management (GET, POST, PUT, DELETE) - Add set default endpoint for address type - Implement addresses.html with full UI (cards, modals, Alpine.js) - Integrate saved addresses in checkout flow - Address selector dropdowns for shipping/billing - Auto-select default addresses - Save new address checkbox option - Add country_iso field alongside country_name - Add address exceptions (NotFound, LimitExceeded, InvalidType) - Max 10 addresses per customer limit - One default address per type (shipping/billing) - Add unit tests for CustomerAddressService - Add integration tests for shop addresses API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
141
alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py
Normal file
141
alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Add country_iso to customer_addresses
|
||||||
|
|
||||||
|
Revision ID: r6f7a8b9c0d1
|
||||||
|
Revises: q5e6f7a8b9c0
|
||||||
|
Create Date: 2026-01-02
|
||||||
|
|
||||||
|
Adds country_iso field to customer_addresses table and renames
|
||||||
|
country to country_name for clarity.
|
||||||
|
|
||||||
|
This migration is idempotent - it checks for existing columns before
|
||||||
|
making changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "r6f7a8b9c0d1"
|
||||||
|
down_revision = "q5e6f7a8b9c0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
# Country name to ISO code mapping for backfill
|
||||||
|
COUNTRY_ISO_MAP = {
|
||||||
|
"Luxembourg": "LU",
|
||||||
|
"Germany": "DE",
|
||||||
|
"France": "FR",
|
||||||
|
"Belgium": "BE",
|
||||||
|
"Netherlands": "NL",
|
||||||
|
"Austria": "AT",
|
||||||
|
"Italy": "IT",
|
||||||
|
"Spain": "ES",
|
||||||
|
"Portugal": "PT",
|
||||||
|
"Poland": "PL",
|
||||||
|
"Czech Republic": "CZ",
|
||||||
|
"Czechia": "CZ",
|
||||||
|
"Slovakia": "SK",
|
||||||
|
"Hungary": "HU",
|
||||||
|
"Romania": "RO",
|
||||||
|
"Bulgaria": "BG",
|
||||||
|
"Greece": "GR",
|
||||||
|
"Croatia": "HR",
|
||||||
|
"Slovenia": "SI",
|
||||||
|
"Estonia": "EE",
|
||||||
|
"Latvia": "LV",
|
||||||
|
"Lithuania": "LT",
|
||||||
|
"Finland": "FI",
|
||||||
|
"Sweden": "SE",
|
||||||
|
"Denmark": "DK",
|
||||||
|
"Ireland": "IE",
|
||||||
|
"Cyprus": "CY",
|
||||||
|
"Malta": "MT",
|
||||||
|
"United Kingdom": "GB",
|
||||||
|
"Switzerland": "CH",
|
||||||
|
"United States": "US",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_column_names(connection, table_name):
|
||||||
|
"""Get list of column names for a table."""
|
||||||
|
result = connection.execute(sa.text(f"PRAGMA table_info({table_name})"))
|
||||||
|
return [row[1] for row in result]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
columns = get_column_names(connection, "customer_addresses")
|
||||||
|
|
||||||
|
# Check if we need to do anything (idempotent check)
|
||||||
|
has_country = "country" in columns
|
||||||
|
has_country_name = "country_name" in columns
|
||||||
|
has_country_iso = "country_iso" in columns
|
||||||
|
|
||||||
|
# If already has new columns, nothing to do
|
||||||
|
if has_country_name and has_country_iso:
|
||||||
|
print(" Columns country_name and country_iso already exist, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
# If has old 'country' column, rename it and add country_iso
|
||||||
|
if has_country and not has_country_name:
|
||||||
|
with op.batch_alter_table("customer_addresses") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"country",
|
||||||
|
new_column_name="country_name",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add country_iso if it doesn't exist
|
||||||
|
if not has_country_iso:
|
||||||
|
with op.batch_alter_table("customer_addresses") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("country_iso", sa.String(5), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill country_iso from country_name
|
||||||
|
for country_name, iso_code in COUNTRY_ISO_MAP.items():
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE customer_addresses SET country_iso = :iso "
|
||||||
|
"WHERE country_name = :name"
|
||||||
|
),
|
||||||
|
{"iso": iso_code, "name": country_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set default for any remaining NULL values
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE customer_addresses SET country_iso = 'LU' "
|
||||||
|
"WHERE country_iso IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make country_iso NOT NULL using batch operation
|
||||||
|
with op.batch_alter_table("customer_addresses") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"country_iso",
|
||||||
|
existing_type=sa.String(5),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
columns = get_column_names(connection, "customer_addresses")
|
||||||
|
|
||||||
|
has_country_name = "country_name" in columns
|
||||||
|
has_country_iso = "country_iso" in columns
|
||||||
|
has_country = "country" in columns
|
||||||
|
|
||||||
|
# Only downgrade if in the new state
|
||||||
|
if has_country_name and not has_country:
|
||||||
|
with op.batch_alter_table("customer_addresses") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"country_name",
|
||||||
|
new_column_name="country",
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_country_iso:
|
||||||
|
with op.batch_alter_table("customer_addresses") as batch_op:
|
||||||
|
batch_op.drop_column("country_iso")
|
||||||
@@ -21,7 +21,7 @@ Authentication:
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
# Import shop routers
|
# Import shop routers
|
||||||
from . import auth, carts, content_pages, messages, orders, products
|
from . import addresses, auth, carts, content_pages, messages, orders, products
|
||||||
|
|
||||||
# Create shop router
|
# Create shop router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -30,6 +30,9 @@ router = APIRouter()
|
|||||||
# SHOP API ROUTES (All vendor-context aware via middleware)
|
# SHOP API ROUTES (All vendor-context aware via middleware)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
# Addresses (authenticated)
|
||||||
|
router.include_router(addresses.router, tags=["shop-addresses"])
|
||||||
|
|
||||||
# Authentication (public)
|
# Authentication (public)
|
||||||
router.include_router(auth.router, tags=["shop-auth"])
|
router.include_router(auth.router, tags=["shop-auth"])
|
||||||
|
|
||||||
|
|||||||
269
app/api/v1/shop/addresses.py
Normal file
269
app/api/v1/shop/addresses.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# app/api/v1/shop/addresses.py
|
||||||
|
"""
|
||||||
|
Shop Addresses API (Customer authenticated)
|
||||||
|
|
||||||
|
Endpoints for managing customer addresses in shop frontend.
|
||||||
|
Uses vendor from request.state (injected by VendorContextMiddleware).
|
||||||
|
Requires customer authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_customer_api
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.exceptions import VendorNotFoundException
|
||||||
|
from app.services.customer_address_service import customer_address_service
|
||||||
|
from models.database.customer import Customer
|
||||||
|
from models.schema.customer import (
|
||||||
|
CustomerAddressCreate,
|
||||||
|
CustomerAddressListResponse,
|
||||||
|
CustomerAddressResponse,
|
||||||
|
CustomerAddressUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/addresses", response_model=CustomerAddressListResponse)
|
||||||
|
def list_addresses(
|
||||||
|
request: Request,
|
||||||
|
customer: Customer = Depends(get_current_customer_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all addresses for authenticated customer.
|
||||||
|
|
||||||
|
Vendor is automatically determined from request context.
|
||||||
|
Returns all addresses sorted by default first, then by creation date.
|
||||||
|
"""
|
||||||
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
if not vendor:
|
||||||
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[SHOP_API] list_addresses for customer {customer.id}",
|
||||||
|
extra={
|
||||||
|
"vendor_id": vendor.id,
|
||||||
|
"vendor_code": vendor.subdomain,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
addresses = customer_address_service.list_addresses(
|
||||||
|
db=db, vendor_id=vendor.id, customer_id=customer.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return CustomerAddressListResponse(
|
||||||
|
addresses=[CustomerAddressResponse.model_validate(a) for a in addresses],
|
||||||
|
total=len(addresses),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/addresses/{address_id}", response_model=CustomerAddressResponse)
|
||||||
|
def get_address(
|
||||||
|
request: Request,
|
||||||
|
address_id: int = Path(..., description="Address ID", gt=0),
|
||||||
|
customer: Customer = Depends(get_current_customer_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get specific address by ID.
|
||||||
|
|
||||||
|
Vendor is automatically determined from request context.
|
||||||
|
Customer can only access their own addresses.
|
||||||
|
"""
|
||||||
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
if not vendor:
|
||||||
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[SHOP_API] get_address {address_id} for customer {customer.id}",
|
||||||
|
extra={
|
||||||
|
"vendor_id": vendor.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"address_id": address_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
address = customer_address_service.get_address(
|
||||||
|
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return CustomerAddressResponse.model_validate(address)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/addresses", response_model=CustomerAddressResponse, status_code=201)
|
||||||
|
def create_address(
|
||||||
|
request: Request,
|
||||||
|
address_data: CustomerAddressCreate,
|
||||||
|
customer: Customer = Depends(get_current_customer_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create new address for authenticated customer.
|
||||||
|
|
||||||
|
Vendor is automatically determined from request context.
|
||||||
|
Maximum 10 addresses per customer.
|
||||||
|
If is_default=True, clears default flag on other addresses of same type.
|
||||||
|
"""
|
||||||
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
if not vendor:
|
||||||
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[SHOP_API] create_address for customer {customer.id}",
|
||||||
|
extra={
|
||||||
|
"vendor_id": vendor.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"address_type": address_data.address_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
address = customer_address_service.create_address(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor.id,
|
||||||
|
customer_id=customer.id,
|
||||||
|
address_data=address_data,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created address {address.id} for customer {customer.id} "
|
||||||
|
f"(type={address_data.address_type})",
|
||||||
|
extra={
|
||||||
|
"address_id": address.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"address_type": address_data.address_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return CustomerAddressResponse.model_validate(address)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/addresses/{address_id}", response_model=CustomerAddressResponse)
|
||||||
|
def update_address(
|
||||||
|
request: Request,
|
||||||
|
address_data: CustomerAddressUpdate,
|
||||||
|
address_id: int = Path(..., description="Address ID", gt=0),
|
||||||
|
customer: Customer = Depends(get_current_customer_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update existing address.
|
||||||
|
|
||||||
|
Vendor is automatically determined from request context.
|
||||||
|
Customer can only update their own addresses.
|
||||||
|
If is_default=True, clears default flag on other addresses of same type.
|
||||||
|
"""
|
||||||
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
if not vendor:
|
||||||
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[SHOP_API] update_address {address_id} for customer {customer.id}",
|
||||||
|
extra={
|
||||||
|
"vendor_id": vendor.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"address_id": address_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
address = customer_address_service.update_address(
|
||||||
|
db=db,
|
||||||
|
vendor_id=vendor.id,
|
||||||
|
customer_id=customer.id,
|
||||||
|
address_id=address_id,
|
||||||
|
address_data=address_data,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Updated address {address_id} for customer {customer.id}",
|
||||||
|
extra={"address_id": address_id, "customer_id": customer.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
return CustomerAddressResponse.model_validate(address)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/addresses/{address_id}", status_code=204)
|
||||||
|
def delete_address(
|
||||||
|
request: Request,
|
||||||
|
address_id: int = Path(..., description="Address ID", gt=0),
|
||||||
|
customer: Customer = Depends(get_current_customer_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete address.
|
||||||
|
|
||||||
|
Vendor is automatically determined from request context.
|
||||||
|
Customer can only delete their own addresses.
|
||||||
|
"""
|
||||||
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
if not vendor:
|
||||||
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[SHOP_API] delete_address {address_id} for customer {customer.id}",
|
||||||
|
extra={
|
||||||
|
"vendor_id": vendor.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"address_id": address_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
customer_address_service.delete_address(
|
||||||
|
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Deleted address {address_id} for customer {customer.id}",
|
||||||
|
extra={"address_id": address_id, "customer_id": customer.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/addresses/{address_id}/default", response_model=CustomerAddressResponse)
|
||||||
|
def set_address_default(
|
||||||
|
request: Request,
|
||||||
|
address_id: int = Path(..., description="Address ID", gt=0),
|
||||||
|
customer: Customer = Depends(get_current_customer_api),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Set address as default for its type.
|
||||||
|
|
||||||
|
Vendor is automatically determined from request context.
|
||||||
|
Clears default flag on other addresses of the same type.
|
||||||
|
"""
|
||||||
|
vendor = getattr(request.state, "vendor", None)
|
||||||
|
if not vendor:
|
||||||
|
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[SHOP_API] set_address_default {address_id} for customer {customer.id}",
|
||||||
|
extra={
|
||||||
|
"vendor_id": vendor.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"address_id": address_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
address = customer_address_service.set_default(
|
||||||
|
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Set address {address_id} as default for customer {customer.id}",
|
||||||
|
extra={
|
||||||
|
"address_id": address_id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"address_type": address.address_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return CustomerAddressResponse.model_validate(address)
|
||||||
@@ -6,6 +6,13 @@ This module provides frontend-friendly exceptions with consistent error codes,
|
|||||||
messages, and HTTP status mappings.
|
messages, and HTTP status mappings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Address exceptions
|
||||||
|
from .address import (
|
||||||
|
AddressLimitExceededException,
|
||||||
|
AddressNotFoundException,
|
||||||
|
InvalidAddressTypeException,
|
||||||
|
)
|
||||||
|
|
||||||
# Admin exceptions
|
# Admin exceptions
|
||||||
from .admin import (
|
from .admin import (
|
||||||
AdminOperationException,
|
AdminOperationException,
|
||||||
|
|||||||
38
app/exceptions/address.py
Normal file
38
app/exceptions/address.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# app/exceptions/address.py
|
||||||
|
"""
|
||||||
|
Address-related custom exceptions.
|
||||||
|
|
||||||
|
Used for customer address management operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import BusinessLogicException, ResourceNotFoundException
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
314
app/services/customer_address_service.py
Normal file
314
app/services/customer_address_service.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# app/services/customer_address_service.py
|
||||||
|
"""
|
||||||
|
Customer Address Service
|
||||||
|
|
||||||
|
Business logic for managing customer addresses with vendor isolation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
AddressLimitExceededException,
|
||||||
|
AddressNotFoundException,
|
||||||
|
)
|
||||||
|
from models.database.customer import CustomerAddress
|
||||||
|
from models.schema.customer import CustomerAddressCreate, CustomerAddressUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAddressService:
|
||||||
|
"""Service for managing customer addresses with vendor isolation."""
|
||||||
|
|
||||||
|
MAX_ADDRESSES_PER_CUSTOMER = 10
|
||||||
|
|
||||||
|
def list_addresses(
|
||||||
|
self, db: Session, vendor_id: int, customer_id: int
|
||||||
|
) -> list[CustomerAddress]:
|
||||||
|
"""
|
||||||
|
Get all addresses for a customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of customer addresses
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
db.query(CustomerAddress)
|
||||||
|
.filter(
|
||||||
|
CustomerAddress.vendor_id == vendor_id,
|
||||||
|
CustomerAddress.customer_id == customer_id,
|
||||||
|
)
|
||||||
|
.order_by(CustomerAddress.is_default.desc(), CustomerAddress.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_address(
|
||||||
|
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||||
|
) -> CustomerAddress:
|
||||||
|
"""
|
||||||
|
Get a specific address with ownership validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
address_id: Address ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Customer address
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AddressNotFoundException: If address not found or doesn't belong to customer
|
||||||
|
"""
|
||||||
|
address = (
|
||||||
|
db.query(CustomerAddress)
|
||||||
|
.filter(
|
||||||
|
CustomerAddress.id == address_id,
|
||||||
|
CustomerAddress.vendor_id == vendor_id,
|
||||||
|
CustomerAddress.customer_id == customer_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
raise AddressNotFoundException(address_id)
|
||||||
|
|
||||||
|
return address
|
||||||
|
|
||||||
|
def get_default_address(
|
||||||
|
self, db: Session, vendor_id: int, customer_id: int, address_type: str
|
||||||
|
) -> CustomerAddress | None:
|
||||||
|
"""
|
||||||
|
Get the default address for a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
address_type: 'shipping' or 'billing'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Default address or None if not set
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
db.query(CustomerAddress)
|
||||||
|
.filter(
|
||||||
|
CustomerAddress.vendor_id == vendor_id,
|
||||||
|
CustomerAddress.customer_id == customer_id,
|
||||||
|
CustomerAddress.address_type == address_type,
|
||||||
|
CustomerAddress.is_default == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_address(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
customer_id: int,
|
||||||
|
address_data: CustomerAddressCreate,
|
||||||
|
) -> CustomerAddress:
|
||||||
|
"""
|
||||||
|
Create a new address for a customer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
address_data: Address creation data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created customer address
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AddressLimitExceededException: If max addresses reached
|
||||||
|
"""
|
||||||
|
# Check address limit
|
||||||
|
current_count = (
|
||||||
|
db.query(CustomerAddress)
|
||||||
|
.filter(
|
||||||
|
CustomerAddress.vendor_id == vendor_id,
|
||||||
|
CustomerAddress.customer_id == customer_id,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_count >= self.MAX_ADDRESSES_PER_CUSTOMER:
|
||||||
|
raise AddressLimitExceededException(self.MAX_ADDRESSES_PER_CUSTOMER)
|
||||||
|
|
||||||
|
# If setting as default, clear other defaults of same type
|
||||||
|
if address_data.is_default:
|
||||||
|
self._clear_other_defaults(
|
||||||
|
db, vendor_id, customer_id, address_data.address_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the address
|
||||||
|
address = CustomerAddress(
|
||||||
|
vendor_id=vendor_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
address_type=address_data.address_type,
|
||||||
|
first_name=address_data.first_name,
|
||||||
|
last_name=address_data.last_name,
|
||||||
|
company=address_data.company,
|
||||||
|
address_line_1=address_data.address_line_1,
|
||||||
|
address_line_2=address_data.address_line_2,
|
||||||
|
city=address_data.city,
|
||||||
|
postal_code=address_data.postal_code,
|
||||||
|
country_name=address_data.country_name,
|
||||||
|
country_iso=address_data.country_iso,
|
||||||
|
is_default=address_data.is_default,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(address)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created address {address.id} for customer {customer_id} "
|
||||||
|
f"(type={address_data.address_type}, default={address_data.is_default})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return address
|
||||||
|
|
||||||
|
def update_address(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
customer_id: int,
|
||||||
|
address_id: int,
|
||||||
|
address_data: CustomerAddressUpdate,
|
||||||
|
) -> CustomerAddress:
|
||||||
|
"""
|
||||||
|
Update an existing address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
address_id: Address ID
|
||||||
|
address_data: Address update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated customer address
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AddressNotFoundException: If address not found
|
||||||
|
"""
|
||||||
|
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||||
|
|
||||||
|
# Update only provided fields
|
||||||
|
update_data = address_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle default flag - clear others if setting to default
|
||||||
|
if update_data.get("is_default") is True:
|
||||||
|
# Use updated type if provided, otherwise current type
|
||||||
|
address_type = update_data.get("address_type", address.address_type)
|
||||||
|
self._clear_other_defaults(
|
||||||
|
db, vendor_id, customer_id, address_type, exclude_id=address_id
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(address, field, value)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(f"Updated address {address_id} for customer {customer_id}")
|
||||||
|
|
||||||
|
return address
|
||||||
|
|
||||||
|
def delete_address(
|
||||||
|
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Delete an address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
address_id: Address ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AddressNotFoundException: If address not found
|
||||||
|
"""
|
||||||
|
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||||
|
|
||||||
|
db.delete(address)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(f"Deleted address {address_id} for customer {customer_id}")
|
||||||
|
|
||||||
|
def set_default(
|
||||||
|
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||||
|
) -> CustomerAddress:
|
||||||
|
"""
|
||||||
|
Set an address as the default for its type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
address_id: Address ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated customer address
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AddressNotFoundException: If address not found
|
||||||
|
"""
|
||||||
|
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||||
|
|
||||||
|
# Clear other defaults of same type
|
||||||
|
self._clear_other_defaults(
|
||||||
|
db, vendor_id, customer_id, address.address_type, exclude_id=address_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set this one as default
|
||||||
|
address.is_default = True
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Set address {address_id} as default {address.address_type} "
|
||||||
|
f"for customer {customer_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return address
|
||||||
|
|
||||||
|
def _clear_other_defaults(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
vendor_id: int,
|
||||||
|
customer_id: int,
|
||||||
|
address_type: str,
|
||||||
|
exclude_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Clear the default flag on other addresses of the same type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
vendor_id: Vendor ID for isolation
|
||||||
|
customer_id: Customer ID
|
||||||
|
address_type: 'shipping' or 'billing'
|
||||||
|
exclude_id: Address ID to exclude from clearing
|
||||||
|
"""
|
||||||
|
query = db.query(CustomerAddress).filter(
|
||||||
|
CustomerAddress.vendor_id == vendor_id,
|
||||||
|
CustomerAddress.customer_id == customer_id,
|
||||||
|
CustomerAddress.address_type == address_type,
|
||||||
|
CustomerAddress.is_default == True, # noqa: E712
|
||||||
|
)
|
||||||
|
|
||||||
|
if exclude_id:
|
||||||
|
query = query.filter(CustomerAddress.id != exclude_id)
|
||||||
|
|
||||||
|
query.update({"is_default": False}, synchronize_session=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
customer_address_service = CustomerAddressService()
|
||||||
@@ -1,15 +1,562 @@
|
|||||||
{# app/templates/shop/account/addresses.html #}
|
{# app/templates/shop/account/addresses.html #}
|
||||||
{% extends "shop/base.html" %}
|
{% extends "shop/base.html" %}
|
||||||
|
|
||||||
{% block title %}My Addresses{% endblock %}
|
{% block title %}My Addresses - {{ vendor.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block alpine_data %}addressesPage(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Addresses</h1>
|
<!-- 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)">
|
||||||
|
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# TODO: Implement address management #}
|
<!-- Loading State -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div x-show="loading" class="flex justify-center py-12">
|
||||||
<p class="text-gray-600 dark:text-gray-400">Address management coming soon...</p>
|
<svg class="animate-spin h-8 w-8 text-primary" style="color: var(--color-primary)" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</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">
|
||||||
|
<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<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">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<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'">
|
||||||
|
<svg class="-ml-0.5 mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
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 -->
|
||||||
|
<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"
|
||||||
|
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">
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</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"
|
||||||
|
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">
|
||||||
|
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -1,15 +1,926 @@
|
|||||||
{# app/templates/shop/checkout.html #}
|
{# app/templates/shop/checkout.html #}
|
||||||
{% extends "shop/base.html" %}
|
{% extends "shop/base.html" %}
|
||||||
|
|
||||||
{% block title %}Checkout{% endblock %}
|
{% block title %}Checkout - {{ vendor.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block alpine_data %}checkoutPage(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
|
|
||||||
|
|
||||||
{# TODO: Implement checkout process #}
|
{# Breadcrumbs #}
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<nav class="mb-6" aria-label="Breadcrumb">
|
||||||
<p class="text-gray-600 dark:text-gray-400">Checkout process coming soon...</p>
|
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<li><a href="{{ base_url }}shop/" class="hover:text-primary">Home</a></li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<a href="{{ base_url }}shop/cart" class="hover:text-primary">Cart</a>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white">Checkout</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# Page Header #}
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
|
||||||
|
|
||||||
|
{# Loading State #}
|
||||||
|
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Empty Cart #}
|
||||||
|
<div x-show="!loading && cartItems.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">Your cart is empty</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-6">Add some products before checking out.</p>
|
||||||
|
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
|
||||||
|
Browse Products
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Checkout Form #}
|
||||||
|
<div x-show="!loading && cartItems.length > 0" x-cloak>
|
||||||
|
<form @submit.prevent="placeOrder()" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
||||||
|
{# Left Column - Forms #}
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
{# Step Indicator #}
|
||||||
|
<div class="flex items-center justify-center mb-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" style="background-color: var(--color-primary)">1</div>
|
||||||
|
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Information</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 2 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 2 ? 'background-color: var(--color-primary)' : ''">2</div>
|
||||||
|
<span class="ml-2 text-sm font-medium" :class="step >= 2 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Shipping</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 3 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 3 ? 'background-color: var(--color-primary)' : ''">3</div>
|
||||||
|
<span class="ml-2 text-sm font-medium" :class="step >= 3 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Review</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Error Message #}
|
||||||
|
<div x-show="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Step 1: Contact & Shipping Address #}
|
||||||
|
<div x-show="step === 1" class="space-y-6">
|
||||||
|
|
||||||
|
{# Contact Information #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Contact Information</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
|
||||||
|
<input type="text" x-model="customer.first_name" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
|
||||||
|
<input type="text" x-model="customer.last_name" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email *</label>
|
||||||
|
<input type="email" x-model="customer.email" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
|
||||||
|
<input type="tel" x-model="customer.phone"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Shipping Address #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Shipping Address</h2>
|
||||||
|
|
||||||
|
{# Saved Addresses Selector (only shown for logged in customers) #}
|
||||||
|
<div x-show="isLoggedIn && shippingAddresses.length > 0" class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
|
||||||
|
<select x-model="selectedShippingAddressId" @change="populateFromSavedAddress('shipping')"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="">Enter a new address</option>
|
||||||
|
<template x-for="addr in shippingAddresses" :key="addr.id">
|
||||||
|
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
|
||||||
|
<input type="text" x-model="shippingAddress.first_name" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
|
||||||
|
<input type="text" x-model="shippingAddress.last_name" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
|
||||||
|
<input type="text" x-model="shippingAddress.company"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
|
||||||
|
<input type="text" x-model="shippingAddress.address_line_1" required placeholder="Street and number"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
|
||||||
|
<input type="text" x-model="shippingAddress.address_line_2" placeholder="Apartment, suite, etc."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
|
||||||
|
<input type="text" x-model="shippingAddress.postal_code" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
|
||||||
|
<input type="text" x-model="shippingAddress.city" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
|
||||||
|
<select x-model="shippingAddress.country_iso" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="">Select a country</option>
|
||||||
|
<template x-for="country in countries" :key="country.code">
|
||||||
|
<option :value="country.code" x-text="country.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Save Address Checkbox (only for new addresses when logged in) #}
|
||||||
|
<div x-show="isLoggedIn && !selectedShippingAddressId" class="md:col-span-2">
|
||||||
|
<label class="flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="saveShippingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||||
|
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Billing Address #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
|
||||||
|
<label class="flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="sameAsShipping" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||||
|
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Same as shipping</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Saved Addresses Selector (only shown for logged in customers when not same as shipping) #}
|
||||||
|
<div x-show="isLoggedIn && !sameAsShipping && billingAddresses.length > 0" class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
|
||||||
|
<select x-model="selectedBillingAddressId" @change="populateFromSavedAddress('billing')"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="">Enter a new address</option>
|
||||||
|
<template x-for="addr in billingAddresses" :key="addr.id">
|
||||||
|
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!sameAsShipping" x-collapse class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
|
||||||
|
<input type="text" x-model="billingAddress.first_name" :required="!sameAsShipping"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
|
||||||
|
<input type="text" x-model="billingAddress.last_name" :required="!sameAsShipping"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
|
||||||
|
<input type="text" x-model="billingAddress.company"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
|
||||||
|
<input type="text" x-model="billingAddress.address_line_1" :required="!sameAsShipping"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
|
||||||
|
<input type="text" x-model="billingAddress.address_line_2"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
|
||||||
|
<input type="text" x-model="billingAddress.postal_code" :required="!sameAsShipping"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
|
||||||
|
<input type="text" x-model="billingAddress.city" :required="!sameAsShipping"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
|
||||||
|
<select x-model="billingAddress.country_iso" :required="!sameAsShipping"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="">Select a country</option>
|
||||||
|
<template x-for="country in countries" :key="country.code">
|
||||||
|
<option :value="country.code" x-text="country.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Save Address Checkbox (only for new addresses when logged in) #}
|
||||||
|
<div x-show="isLoggedIn && !selectedBillingAddressId" class="md:col-span-2">
|
||||||
|
<label class="flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="saveBillingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||||
|
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" @click="goToStep(2)"
|
||||||
|
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
|
||||||
|
style="background-color: var(--color-primary)">
|
||||||
|
Continue to Shipping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Step 2: Shipping Method #}
|
||||||
|
<div x-show="step === 2" class="space-y-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Shipping Method</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||||
|
:class="shippingMethod === 'standard' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
|
||||||
|
:style="shippingMethod === 'standard' ? 'border-color: var(--color-primary)' : ''">
|
||||||
|
<input type="radio" name="shipping" value="standard" x-model="shippingMethod" class="hidden">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">Standard Shipping</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">3-5 business days</p>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white" x-text="subtotal >= 50 ? 'FREE' : '5.99'"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||||
|
:class="shippingMethod === 'express' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
|
||||||
|
:style="shippingMethod === 'express' ? 'border-color: var(--color-primary)' : ''">
|
||||||
|
<input type="radio" name="shipping" value="express" x-model="shippingMethod" class="hidden">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">Express Shipping</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">1-2 business days</p>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">9.99</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Order Notes #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Notes (Optional)</h2>
|
||||||
|
<textarea x-model="customerNotes" rows="3" placeholder="Special instructions for your order..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" @click="goToStep(1)"
|
||||||
|
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="goToStep(3)"
|
||||||
|
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
|
||||||
|
style="background-color: var(--color-primary)">
|
||||||
|
Review Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Step 3: Review & Place Order #}
|
||||||
|
<div x-show="step === 3" class="space-y-6">
|
||||||
|
{# Review Contact Info #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Contact Information</h2>
|
||||||
|
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300" x-text="customer.first_name + ' ' + customer.last_name"></p>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400" x-text="customer.email"></p>
|
||||||
|
<p x-show="customer.phone" class="text-gray-600 dark:text-gray-400" x-text="customer.phone"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Review Addresses #}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Address</h2>
|
||||||
|
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white" x-text="shippingAddress.first_name + ' ' + shippingAddress.last_name"></p>
|
||||||
|
<p x-show="shippingAddress.company" x-text="shippingAddress.company"></p>
|
||||||
|
<p x-text="shippingAddress.address_line_1"></p>
|
||||||
|
<p x-show="shippingAddress.address_line_2" x-text="shippingAddress.address_line_2"></p>
|
||||||
|
<p x-text="shippingAddress.postal_code + ' ' + shippingAddress.city"></p>
|
||||||
|
<p x-text="getCountryName(shippingAddress.country_iso)"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
|
||||||
|
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
|
||||||
|
<template x-if="sameAsShipping">
|
||||||
|
<p class="italic">Same as shipping address</p>
|
||||||
|
</template>
|
||||||
|
<template x-if="!sameAsShipping">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white" x-text="billingAddress.first_name + ' ' + billingAddress.last_name"></p>
|
||||||
|
<p x-show="billingAddress.company" x-text="billingAddress.company"></p>
|
||||||
|
<p x-text="billingAddress.address_line_1"></p>
|
||||||
|
<p x-show="billingAddress.address_line_2" x-text="billingAddress.address_line_2"></p>
|
||||||
|
<p x-text="billingAddress.postal_code + ' ' + billingAddress.city"></p>
|
||||||
|
<p x-text="getCountryName(billingAddress.country_iso)"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Review Shipping #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Method</h2>
|
||||||
|
<button type="button" @click="goToStep(2)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300" x-text="shippingMethod === 'express' ? 'Express Shipping (1-2 business days)' : 'Standard Shipping (3-5 business days)'"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Order Items Review #}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Order Items</h2>
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<template x-for="item in cartItems" :key="item.product_id">
|
||||||
|
<div class="py-4 flex items-center gap-4">
|
||||||
|
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
|
||||||
|
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||||
|
class="w-16 h-16 object-cover rounded-lg">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Qty: <span x-text="item.quantity"></span></p>
|
||||||
|
</div>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" @click="goToStep(2)"
|
||||||
|
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button type="submit" :disabled="submitting"
|
||||||
|
class="px-8 py-3 text-white rounded-lg font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style="background-color: var(--color-primary)">
|
||||||
|
<span x-show="!submitting">Place Order</span>
|
||||||
|
<span x-show="submitting" class="flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Processing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Right Column - Order Summary #}
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 sticky top-4">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
Order Summary
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{# Cart Items Preview #}
|
||||||
|
<div class="space-y-3 mb-6 max-h-64 overflow-y-auto">
|
||||||
|
<template x-for="item in cartItems" :key="item.product_id">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="relative">
|
||||||
|
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
|
||||||
|
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||||
|
class="w-12 h-12 object-cover rounded">
|
||||||
|
<span class="absolute -top-2 -right-2 w-5 h-5 bg-gray-500 text-white text-xs rounded-full flex items-center justify-center" x-text="item.quantity"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Totals #}
|
||||||
|
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + subtotal.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white" x-text="shippingCost === 0 ? 'FREE' : '€' + shippingCost.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Tax (incl.)</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + tax.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-lg font-bold pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="text-gray-900 dark:text-white">Total</span>
|
||||||
|
<span style="color: var(--color-primary)" x-text="'€' + total.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
|
||||||
|
Free shipping on orders over €50
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
function checkoutPage() {
|
||||||
|
return {
|
||||||
|
...shopLayoutData(),
|
||||||
|
|
||||||
|
// State
|
||||||
|
loading: true,
|
||||||
|
submitting: false,
|
||||||
|
error: '',
|
||||||
|
step: 1,
|
||||||
|
|
||||||
|
// Cart data
|
||||||
|
cartItems: [],
|
||||||
|
|
||||||
|
// Customer info
|
||||||
|
customer: {
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
phone: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// Saved addresses (for logged in customers)
|
||||||
|
isLoggedIn: false,
|
||||||
|
savedAddresses: [],
|
||||||
|
selectedShippingAddressId: '',
|
||||||
|
selectedBillingAddressId: '',
|
||||||
|
saveShippingAddress: false,
|
||||||
|
saveBillingAddress: false,
|
||||||
|
|
||||||
|
// Computed filtered addresses by type
|
||||||
|
get shippingAddresses() {
|
||||||
|
return this.savedAddresses.filter(a => a.address_type === 'shipping');
|
||||||
|
},
|
||||||
|
get billingAddresses() {
|
||||||
|
return this.savedAddresses.filter(a => a.address_type === 'billing');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Shipping address
|
||||||
|
shippingAddress: {
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
company: '',
|
||||||
|
address_line_1: '',
|
||||||
|
address_line_2: '',
|
||||||
|
city: '',
|
||||||
|
postal_code: '',
|
||||||
|
country_iso: 'LU'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Billing address
|
||||||
|
billingAddress: {
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
company: '',
|
||||||
|
address_line_1: '',
|
||||||
|
address_line_2: '',
|
||||||
|
city: '',
|
||||||
|
postal_code: '',
|
||||||
|
country_iso: 'LU'
|
||||||
|
},
|
||||||
|
|
||||||
|
sameAsShipping: true,
|
||||||
|
shippingMethod: 'standard',
|
||||||
|
customerNotes: '',
|
||||||
|
|
||||||
|
// Countries list
|
||||||
|
countries: [
|
||||||
|
{ code: 'LU', name: 'Luxembourg' },
|
||||||
|
{ code: 'DE', name: 'Germany' },
|
||||||
|
{ code: 'FR', name: 'France' },
|
||||||
|
{ code: 'BE', name: 'Belgium' },
|
||||||
|
{ code: 'NL', name: 'Netherlands' },
|
||||||
|
{ code: 'AT', name: 'Austria' },
|
||||||
|
{ code: 'IT', name: 'Italy' },
|
||||||
|
{ code: 'ES', name: 'Spain' },
|
||||||
|
{ code: 'PT', name: 'Portugal' },
|
||||||
|
{ code: 'PL', name: 'Poland' },
|
||||||
|
{ code: 'CZ', name: 'Czech Republic' },
|
||||||
|
{ code: 'SK', name: 'Slovakia' },
|
||||||
|
{ code: 'HU', name: 'Hungary' },
|
||||||
|
{ code: 'SI', name: 'Slovenia' },
|
||||||
|
{ code: 'HR', name: 'Croatia' },
|
||||||
|
{ code: 'RO', name: 'Romania' },
|
||||||
|
{ code: 'BG', name: 'Bulgaria' },
|
||||||
|
{ code: 'GR', name: 'Greece' },
|
||||||
|
{ code: 'IE', name: 'Ireland' },
|
||||||
|
{ code: 'DK', name: 'Denmark' },
|
||||||
|
{ code: 'SE', name: 'Sweden' },
|
||||||
|
{ code: 'FI', name: 'Finland' },
|
||||||
|
{ code: 'EE', name: 'Estonia' },
|
||||||
|
{ code: 'LV', name: 'Latvia' },
|
||||||
|
{ code: 'LT', name: 'Lithuania' },
|
||||||
|
{ code: 'MT', name: 'Malta' },
|
||||||
|
{ code: 'CY', name: 'Cyprus' }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
get subtotal() {
|
||||||
|
return this.cartItems.reduce((sum, item) => sum + (parseFloat(item.price) * item.quantity), 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
get shippingCost() {
|
||||||
|
if (this.shippingMethod === 'express') return 9.99;
|
||||||
|
return this.subtotal >= 50 ? 0 : 5.99;
|
||||||
|
},
|
||||||
|
|
||||||
|
get tax() {
|
||||||
|
// VAT is included in price, calculate the VAT portion (17% for LU)
|
||||||
|
const vatRate = 0.17;
|
||||||
|
return this.subtotal * vatRate / (1 + vatRate);
|
||||||
|
},
|
||||||
|
|
||||||
|
get total() {
|
||||||
|
return this.subtotal + this.shippingCost;
|
||||||
|
},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
console.log('[CHECKOUT] Initializing...');
|
||||||
|
|
||||||
|
// Initialize session
|
||||||
|
if (typeof shopLayoutData === 'function') {
|
||||||
|
const baseData = shopLayoutData();
|
||||||
|
if (baseData.init) {
|
||||||
|
baseData.init.call(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if customer is logged in and pre-fill data
|
||||||
|
await this.loadCustomerData();
|
||||||
|
|
||||||
|
// Load cart
|
||||||
|
await this.loadCart();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCustomerData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/shop/auth/me');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this.isLoggedIn = true;
|
||||||
|
|
||||||
|
// Pre-fill customer info
|
||||||
|
this.customer.first_name = data.first_name || '';
|
||||||
|
this.customer.last_name = data.last_name || '';
|
||||||
|
this.customer.email = data.email || '';
|
||||||
|
this.customer.phone = data.phone || '';
|
||||||
|
|
||||||
|
// Pre-fill shipping address with customer name
|
||||||
|
this.shippingAddress.first_name = data.first_name || '';
|
||||||
|
this.shippingAddress.last_name = data.last_name || '';
|
||||||
|
|
||||||
|
console.log('[CHECKOUT] Customer data loaded');
|
||||||
|
|
||||||
|
// Load saved addresses for logged in customer
|
||||||
|
await this.loadSavedAddresses();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[CHECKOUT] No customer logged in or error:', error);
|
||||||
|
this.isLoggedIn = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSavedAddresses() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/shop/addresses');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this.savedAddresses = data.addresses || [];
|
||||||
|
console.log('[CHECKOUT] Saved addresses loaded:', this.savedAddresses.length);
|
||||||
|
|
||||||
|
// Auto-select default shipping address
|
||||||
|
const defaultShipping = this.shippingAddresses.find(a => a.is_default);
|
||||||
|
if (defaultShipping) {
|
||||||
|
this.selectedShippingAddressId = defaultShipping.id;
|
||||||
|
this.populateFromSavedAddress('shipping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select default billing address
|
||||||
|
const defaultBilling = this.billingAddresses.find(a => a.is_default);
|
||||||
|
if (defaultBilling) {
|
||||||
|
this.selectedBillingAddressId = defaultBilling.id;
|
||||||
|
this.populateFromSavedAddress('billing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CHECKOUT] Failed to load saved addresses:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
populateFromSavedAddress(type) {
|
||||||
|
const addressId = type === 'shipping' ? this.selectedShippingAddressId : this.selectedBillingAddressId;
|
||||||
|
const addresses = type === 'shipping' ? this.shippingAddresses : this.billingAddresses;
|
||||||
|
const targetAddress = type === 'shipping' ? this.shippingAddress : this.billingAddress;
|
||||||
|
|
||||||
|
if (!addressId) {
|
||||||
|
// Clear form when "Enter a new address" is selected
|
||||||
|
targetAddress.first_name = type === 'shipping' ? this.customer.first_name : '';
|
||||||
|
targetAddress.last_name = type === 'shipping' ? this.customer.last_name : '';
|
||||||
|
targetAddress.company = '';
|
||||||
|
targetAddress.address_line_1 = '';
|
||||||
|
targetAddress.address_line_2 = '';
|
||||||
|
targetAddress.city = '';
|
||||||
|
targetAddress.postal_code = '';
|
||||||
|
targetAddress.country_iso = 'LU';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedAddr = addresses.find(a => a.id == addressId);
|
||||||
|
if (savedAddr) {
|
||||||
|
targetAddress.first_name = savedAddr.first_name || '';
|
||||||
|
targetAddress.last_name = savedAddr.last_name || '';
|
||||||
|
targetAddress.company = savedAddr.company || '';
|
||||||
|
targetAddress.address_line_1 = savedAddr.address_line_1 || '';
|
||||||
|
targetAddress.address_line_2 = savedAddr.address_line_2 || '';
|
||||||
|
targetAddress.city = savedAddr.city || '';
|
||||||
|
targetAddress.postal_code = savedAddr.postal_code || '';
|
||||||
|
targetAddress.country_iso = savedAddr.country_iso || 'LU';
|
||||||
|
console.log(`[CHECKOUT] Populated ${type} address from saved:`, savedAddr.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatAddressOption(addr) {
|
||||||
|
const name = `${addr.first_name} ${addr.last_name}`.trim();
|
||||||
|
const location = `${addr.address_line_1}, ${addr.postal_code} ${addr.city}`;
|
||||||
|
const defaultBadge = addr.is_default ? ' (Default)' : '';
|
||||||
|
return `${name} - ${location}${defaultBadge}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadCart() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this.cartItems = data.items || [];
|
||||||
|
console.log('[CHECKOUT] Cart loaded:', this.cartItems.length, 'items');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CHECKOUT] Failed to load cart:', error);
|
||||||
|
this.error = 'Failed to load cart';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToStep(newStep) {
|
||||||
|
// Validate current step before moving forward
|
||||||
|
if (newStep > this.step) {
|
||||||
|
if (this.step === 1 && !this.validateStep1()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.step = newStep;
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
|
||||||
|
validateStep1() {
|
||||||
|
// Validate customer info
|
||||||
|
if (!this.customer.first_name || !this.customer.last_name || !this.customer.email) {
|
||||||
|
this.error = 'Please fill in all required contact fields';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(this.customer.email)) {
|
||||||
|
this.error = 'Please enter a valid email address';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate shipping address
|
||||||
|
if (!this.shippingAddress.first_name || !this.shippingAddress.last_name ||
|
||||||
|
!this.shippingAddress.address_line_1 || !this.shippingAddress.city ||
|
||||||
|
!this.shippingAddress.postal_code || !this.shippingAddress.country_iso) {
|
||||||
|
this.error = 'Please fill in all required shipping address fields';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate billing address if not same as shipping
|
||||||
|
if (!this.sameAsShipping) {
|
||||||
|
if (!this.billingAddress.first_name || !this.billingAddress.last_name ||
|
||||||
|
!this.billingAddress.address_line_1 || !this.billingAddress.city ||
|
||||||
|
!this.billingAddress.postal_code || !this.billingAddress.country_iso) {
|
||||||
|
this.error = 'Please fill in all required billing address fields';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error = '';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCountryName(code) {
|
||||||
|
const country = this.countries.find(c => c.code === code);
|
||||||
|
return country ? country.name : code;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveNewAddresses() {
|
||||||
|
// Save shipping address if checkbox is checked and it's a new address
|
||||||
|
if (this.saveShippingAddress && !this.selectedShippingAddressId) {
|
||||||
|
try {
|
||||||
|
const country = this.countries.find(c => c.code === this.shippingAddress.country_iso);
|
||||||
|
const addressData = {
|
||||||
|
address_type: 'shipping',
|
||||||
|
first_name: this.shippingAddress.first_name,
|
||||||
|
last_name: this.shippingAddress.last_name,
|
||||||
|
company: this.shippingAddress.company || null,
|
||||||
|
address_line_1: this.shippingAddress.address_line_1,
|
||||||
|
address_line_2: this.shippingAddress.address_line_2 || null,
|
||||||
|
city: this.shippingAddress.city,
|
||||||
|
postal_code: this.shippingAddress.postal_code,
|
||||||
|
country_name: country ? country.name : this.shippingAddress.country_iso,
|
||||||
|
country_iso: this.shippingAddress.country_iso,
|
||||||
|
is_default: this.shippingAddresses.length === 0 // Make default if first address
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/v1/shop/addresses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(addressData)
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('[CHECKOUT] Shipping address saved');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CHECKOUT] Failed to save shipping address:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save billing address if checkbox is checked, it's a new address, and not same as shipping
|
||||||
|
if (this.saveBillingAddress && !this.selectedBillingAddressId && !this.sameAsShipping) {
|
||||||
|
try {
|
||||||
|
const country = this.countries.find(c => c.code === this.billingAddress.country_iso);
|
||||||
|
const addressData = {
|
||||||
|
address_type: 'billing',
|
||||||
|
first_name: this.billingAddress.first_name,
|
||||||
|
last_name: this.billingAddress.last_name,
|
||||||
|
company: this.billingAddress.company || null,
|
||||||
|
address_line_1: this.billingAddress.address_line_1,
|
||||||
|
address_line_2: this.billingAddress.address_line_2 || null,
|
||||||
|
city: this.billingAddress.city,
|
||||||
|
postal_code: this.billingAddress.postal_code,
|
||||||
|
country_name: country ? country.name : this.billingAddress.country_iso,
|
||||||
|
country_iso: this.billingAddress.country_iso,
|
||||||
|
is_default: this.billingAddresses.length === 0 // Make default if first address
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/v1/shop/addresses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(addressData)
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('[CHECKOUT] Billing address saved');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CHECKOUT] Failed to save billing address:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async placeOrder() {
|
||||||
|
this.error = '';
|
||||||
|
this.submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save new addresses if requested (only for logged in users with new addresses)
|
||||||
|
if (this.isLoggedIn) {
|
||||||
|
await this.saveNewAddresses();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build order data
|
||||||
|
const orderData = {
|
||||||
|
items: this.cartItems.map(item => ({
|
||||||
|
product_id: item.product_id,
|
||||||
|
quantity: item.quantity
|
||||||
|
})),
|
||||||
|
customer: {
|
||||||
|
first_name: this.customer.first_name,
|
||||||
|
last_name: this.customer.last_name,
|
||||||
|
email: this.customer.email,
|
||||||
|
phone: this.customer.phone || null
|
||||||
|
},
|
||||||
|
shipping_address: {
|
||||||
|
first_name: this.shippingAddress.first_name,
|
||||||
|
last_name: this.shippingAddress.last_name,
|
||||||
|
company: this.shippingAddress.company || null,
|
||||||
|
address_line_1: this.shippingAddress.address_line_1,
|
||||||
|
address_line_2: this.shippingAddress.address_line_2 || null,
|
||||||
|
city: this.shippingAddress.city,
|
||||||
|
postal_code: this.shippingAddress.postal_code,
|
||||||
|
country_iso: this.shippingAddress.country_iso
|
||||||
|
},
|
||||||
|
billing_address: this.sameAsShipping ? null : {
|
||||||
|
first_name: this.billingAddress.first_name,
|
||||||
|
last_name: this.billingAddress.last_name,
|
||||||
|
company: this.billingAddress.company || null,
|
||||||
|
address_line_1: this.billingAddress.address_line_1,
|
||||||
|
address_line_2: this.billingAddress.address_line_2 || null,
|
||||||
|
city: this.billingAddress.city,
|
||||||
|
postal_code: this.billingAddress.postal_code,
|
||||||
|
country_iso: this.billingAddress.country_iso
|
||||||
|
},
|
||||||
|
shipping_method: this.shippingMethod,
|
||||||
|
customer_notes: this.customerNotes || null,
|
||||||
|
session_id: this.sessionId
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[CHECKOUT] Placing order:', orderData);
|
||||||
|
|
||||||
|
const response = await fetch('/api/v1/shop/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(orderData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.detail || 'Failed to place order');
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await response.json();
|
||||||
|
console.log('[CHECKOUT] Order placed:', order.order_number);
|
||||||
|
|
||||||
|
// Redirect to confirmation page
|
||||||
|
window.location.href = '{{ base_url }}shop/order-confirmation?order=' + order.order_number;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CHECKOUT] Error placing order:', error);
|
||||||
|
this.error = error.message || 'Failed to place order. Please try again.';
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ class CustomerAddress(Base, TimestampMixin):
|
|||||||
address_line_2 = Column(String(255))
|
address_line_2 = Column(String(255))
|
||||||
city = Column(String(100), nullable=False)
|
city = Column(String(100), nullable=False)
|
||||||
postal_code = Column(String(20), nullable=False)
|
postal_code = Column(String(20), nullable=False)
|
||||||
country = Column(String(100), nullable=False)
|
country_name = Column(String(100), nullable=False)
|
||||||
|
country_iso = Column(String(5), nullable=False)
|
||||||
is_default = Column(Boolean, default=False)
|
is_default = Column(Boolean, default=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ class CustomerAddressCreate(BaseModel):
|
|||||||
address_line_2: str | None = Field(None, max_length=255)
|
address_line_2: str | None = Field(None, max_length=255)
|
||||||
city: str = Field(..., min_length=1, max_length=100)
|
city: str = Field(..., min_length=1, max_length=100)
|
||||||
postal_code: str = Field(..., min_length=1, max_length=20)
|
postal_code: str = Field(..., min_length=1, max_length=20)
|
||||||
country: str = Field(..., min_length=2, max_length=100)
|
country_name: str = Field(..., min_length=2, max_length=100)
|
||||||
|
country_iso: str = Field(..., min_length=2, max_length=5)
|
||||||
is_default: bool = Field(default=False)
|
is_default: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +142,8 @@ class CustomerAddressUpdate(BaseModel):
|
|||||||
address_line_2: str | None = Field(None, max_length=255)
|
address_line_2: str | None = Field(None, max_length=255)
|
||||||
city: str | None = Field(None, min_length=1, max_length=100)
|
city: str | None = Field(None, min_length=1, max_length=100)
|
||||||
postal_code: str | None = Field(None, min_length=1, max_length=20)
|
postal_code: str | None = Field(None, min_length=1, max_length=20)
|
||||||
country: str | None = Field(None, min_length=2, max_length=100)
|
country_name: str | None = Field(None, min_length=2, max_length=100)
|
||||||
|
country_iso: str | None = Field(None, min_length=2, max_length=5)
|
||||||
is_default: bool | None = None
|
is_default: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +161,8 @@ class CustomerAddressResponse(BaseModel):
|
|||||||
address_line_2: str | None
|
address_line_2: str | None
|
||||||
city: str
|
city: str
|
||||||
postal_code: str
|
postal_code: str
|
||||||
country: str
|
country_name: str
|
||||||
|
country_iso: str
|
||||||
is_default: bool
|
is_default: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -167,6 +170,13 @@ class CustomerAddressResponse(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAddressListResponse(BaseModel):
|
||||||
|
"""Schema for customer address list response."""
|
||||||
|
|
||||||
|
addresses: list[CustomerAddressResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Customer Preferences
|
# Customer Preferences
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
54
tests/fixtures/customer_fixtures.py
vendored
54
tests/fixtures/customer_fixtures.py
vendored
@@ -32,7 +32,7 @@ def test_customer(db, test_vendor):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_customer_address(db, test_vendor, test_customer):
|
def test_customer_address(db, test_vendor, test_customer):
|
||||||
"""Create a test customer address."""
|
"""Create a test customer shipping address."""
|
||||||
address = CustomerAddress(
|
address = CustomerAddress(
|
||||||
vendor_id=test_vendor.id,
|
vendor_id=test_vendor.id,
|
||||||
customer_id=test_customer.id,
|
customer_id=test_customer.id,
|
||||||
@@ -42,7 +42,8 @@ def test_customer_address(db, test_vendor, test_customer):
|
|||||||
address_line_1="123 Main St",
|
address_line_1="123 Main St",
|
||||||
city="Luxembourg",
|
city="Luxembourg",
|
||||||
postal_code="L-1234",
|
postal_code="L-1234",
|
||||||
country="Luxembourg",
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
is_default=True,
|
is_default=True,
|
||||||
)
|
)
|
||||||
db.add(address)
|
db.add(address)
|
||||||
@@ -51,6 +52,55 @@ def test_customer_address(db, test_vendor, test_customer):
|
|||||||
return address
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_customer_billing_address(db, test_vendor, test_customer):
|
||||||
|
"""Create a test customer billing address."""
|
||||||
|
address = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_type="billing",
|
||||||
|
first_name="John",
|
||||||
|
last_name="Doe",
|
||||||
|
company="Test Company S.A.",
|
||||||
|
address_line_1="456 Business Ave",
|
||||||
|
city="Luxembourg",
|
||||||
|
postal_code="L-5678",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
db.add(address)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(address)
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_customer_multiple_addresses(db, test_vendor, test_customer):
|
||||||
|
"""Create multiple addresses for testing limits and listing."""
|
||||||
|
addresses = []
|
||||||
|
for i in range(3):
|
||||||
|
address = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_type="shipping" if i % 2 == 0 else "billing",
|
||||||
|
first_name=f"Name{i}",
|
||||||
|
last_name="Test",
|
||||||
|
address_line_1=f"{i}00 Test Street",
|
||||||
|
city="Luxembourg",
|
||||||
|
postal_code=f"L-{1000+i}",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=(i == 0),
|
||||||
|
)
|
||||||
|
db.add(address)
|
||||||
|
addresses.append(address)
|
||||||
|
db.commit()
|
||||||
|
for addr in addresses:
|
||||||
|
db.refresh(addr)
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_order(db, test_vendor, test_customer, test_customer_address):
|
def test_order(db, test_vendor, test_customer, test_customer_address):
|
||||||
"""Create a test order with customer/address snapshots."""
|
"""Create a test order with customer/address snapshots."""
|
||||||
|
|||||||
1
tests/integration/api/v1/shop/__init__.py
Normal file
1
tests/integration/api/v1/shop/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Shop API integration tests
|
||||||
621
tests/integration/api/v1/shop/test_addresses.py
Normal file
621
tests/integration/api/v1/shop/test_addresses.py
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
# tests/integration/api/v1/shop/test_addresses.py
|
||||||
|
"""Integration tests for shop addresses API endpoints.
|
||||||
|
|
||||||
|
Tests the /api/v1/shop/addresses/* endpoints.
|
||||||
|
All endpoints require customer JWT authentication with vendor context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from models.database.customer import Customer, CustomerAddress
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_customer(db, test_vendor):
|
||||||
|
"""Create a test customer for shop API tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
customer = Customer(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
email="shopcustomer@example.com",
|
||||||
|
hashed_password=auth_manager.hash_password("testpass123"),
|
||||||
|
first_name="Shop",
|
||||||
|
last_name="Customer",
|
||||||
|
customer_number="SHOP001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(customer)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_customer_token(shop_customer, test_vendor):
|
||||||
|
"""Create JWT token for shop customer."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
|
||||||
|
expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
|
||||||
|
expire = datetime.now(UTC) + expires_delta
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"sub": str(shop_customer.id),
|
||||||
|
"email": shop_customer.email,
|
||||||
|
"vendor_id": test_vendor.id,
|
||||||
|
"type": "customer",
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
payload, auth_manager.secret_key, algorithm=auth_manager.algorithm
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_customer_headers(shop_customer_token):
|
||||||
|
"""Get authentication headers for shop customer."""
|
||||||
|
return {"Authorization": f"Bearer {shop_customer_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def customer_address(db, test_vendor, shop_customer):
|
||||||
|
"""Create a test address for shop customer."""
|
||||||
|
address = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=shop_customer.id,
|
||||||
|
address_type="shipping",
|
||||||
|
first_name="Ship",
|
||||||
|
last_name="Address",
|
||||||
|
address_line_1="123 Shipping St",
|
||||||
|
city="Luxembourg",
|
||||||
|
postal_code="L-1234",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
db.add(address)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(address)
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def customer_billing_address(db, test_vendor, shop_customer):
|
||||||
|
"""Create a billing address for shop customer."""
|
||||||
|
address = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=shop_customer.id,
|
||||||
|
address_type="billing",
|
||||||
|
first_name="Bill",
|
||||||
|
last_name="Address",
|
||||||
|
company="Test Company",
|
||||||
|
address_line_1="456 Billing Ave",
|
||||||
|
city="Esch-sur-Alzette",
|
||||||
|
postal_code="L-5678",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
db.add(address)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(address)
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_customer(db, test_vendor):
|
||||||
|
"""Create another customer for testing access controls."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
customer = Customer(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
email="othercustomer@example.com",
|
||||||
|
hashed_password=auth_manager.hash_password("otherpass123"),
|
||||||
|
first_name="Other",
|
||||||
|
last_name="Customer",
|
||||||
|
customer_number="OTHER001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(customer)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_customer_address(db, test_vendor, other_customer):
|
||||||
|
"""Create an address for another customer."""
|
||||||
|
address = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=other_customer.id,
|
||||||
|
address_type="shipping",
|
||||||
|
first_name="Other",
|
||||||
|
last_name="Address",
|
||||||
|
address_line_1="999 Other St",
|
||||||
|
city="Differdange",
|
||||||
|
postal_code="L-9999",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
db.add(address)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(address)
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopAddressesListAPI:
|
||||||
|
"""Test shop addresses list endpoint at /api/v1/shop/addresses."""
|
||||||
|
|
||||||
|
def test_list_addresses_requires_authentication(self, client, test_vendor):
|
||||||
|
"""Test that listing addresses requires authentication."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
response = client.get("/api/v1/shop/addresses")
|
||||||
|
assert response.status_code in [401, 403]
|
||||||
|
|
||||||
|
def test_list_addresses_success(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test listing customer addresses successfully."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/addresses",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "addresses" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["addresses"][0]["first_name"] == "Ship"
|
||||||
|
|
||||||
|
def test_list_addresses_empty(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test listing addresses when customer has none."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/addresses",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["addresses"] == []
|
||||||
|
|
||||||
|
def test_list_addresses_multiple_types(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
customer_address,
|
||||||
|
customer_billing_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test listing addresses includes both shipping and billing."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/addresses",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
|
||||||
|
types = {addr["address_type"] for addr in data["addresses"]}
|
||||||
|
assert "shipping" in types
|
||||||
|
assert "billing" in types
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopAddressDetailAPI:
|
||||||
|
"""Test shop address detail endpoint at /api/v1/shop/addresses/{address_id}."""
|
||||||
|
|
||||||
|
def test_get_address_success(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test getting address details successfully."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/shop/addresses/{customer_address.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == customer_address.id
|
||||||
|
assert data["first_name"] == "Ship"
|
||||||
|
assert data["country_iso"] == "LU"
|
||||||
|
assert data["country_name"] == "Luxembourg"
|
||||||
|
|
||||||
|
def test_get_address_not_found(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test getting non-existent address returns 404."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/addresses/99999",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_get_address_other_customer(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
other_customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test cannot access another customer's address."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/shop/addresses/{other_customer_address.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 404 to prevent enumeration
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopAddressCreateAPI:
|
||||||
|
"""Test shop address creation at POST /api/v1/shop/addresses."""
|
||||||
|
|
||||||
|
def test_create_address_success(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test creating a new address."""
|
||||||
|
address_data = {
|
||||||
|
"address_type": "shipping",
|
||||||
|
"first_name": "New",
|
||||||
|
"last_name": "Address",
|
||||||
|
"address_line_1": "789 New St",
|
||||||
|
"city": "Luxembourg",
|
||||||
|
"postal_code": "L-1111",
|
||||||
|
"country_name": "Luxembourg",
|
||||||
|
"country_iso": "LU",
|
||||||
|
"is_default": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/shop/addresses",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
json=address_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["first_name"] == "New"
|
||||||
|
assert data["last_name"] == "Address"
|
||||||
|
assert data["country_iso"] == "LU"
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
def test_create_address_with_company(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test creating address with company name."""
|
||||||
|
address_data = {
|
||||||
|
"address_type": "billing",
|
||||||
|
"first_name": "Business",
|
||||||
|
"last_name": "Address",
|
||||||
|
"company": "Acme Corp",
|
||||||
|
"address_line_1": "100 Business Park",
|
||||||
|
"city": "Luxembourg",
|
||||||
|
"postal_code": "L-2222",
|
||||||
|
"country_name": "Luxembourg",
|
||||||
|
"country_iso": "LU",
|
||||||
|
"is_default": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/shop/addresses",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
json=address_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["company"] == "Acme Corp"
|
||||||
|
|
||||||
|
def test_create_address_validation_error(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test validation error for missing required fields."""
|
||||||
|
address_data = {
|
||||||
|
"address_type": "shipping",
|
||||||
|
"first_name": "Test",
|
||||||
|
# Missing required fields
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/shop/addresses",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
json=address_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopAddressUpdateAPI:
|
||||||
|
"""Test shop address update at PUT /api/v1/shop/addresses/{address_id}."""
|
||||||
|
|
||||||
|
def test_update_address_success(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test updating an address."""
|
||||||
|
update_data = {
|
||||||
|
"first_name": "Updated",
|
||||||
|
"city": "Esch-sur-Alzette",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/shop/addresses/{customer_address.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
json=update_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["first_name"] == "Updated"
|
||||||
|
assert data["city"] == "Esch-sur-Alzette"
|
||||||
|
|
||||||
|
def test_update_address_not_found(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test updating non-existent address returns 404."""
|
||||||
|
update_data = {"first_name": "Test"}
|
||||||
|
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/shop/addresses/99999",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
json=update_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_address_other_customer(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
other_customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test cannot update another customer's address."""
|
||||||
|
update_data = {"first_name": "Hacked"}
|
||||||
|
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/shop/addresses/{other_customer_address.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
json=update_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopAddressDeleteAPI:
|
||||||
|
"""Test shop address deletion at DELETE /api/v1/shop/addresses/{address_id}."""
|
||||||
|
|
||||||
|
def test_delete_address_success(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test deleting an address."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1/shop/addresses/{customer_address.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
def test_delete_address_not_found(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test deleting non-existent address returns 404."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/shop/addresses/99999",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_address_other_customer(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
other_customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test cannot delete another customer's address."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1/shop/addresses/{other_customer_address.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopAddressSetDefaultAPI:
|
||||||
|
"""Test set address as default at PUT /api/v1/shop/addresses/{address_id}/default."""
|
||||||
|
|
||||||
|
def test_set_default_success(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
db,
|
||||||
|
):
|
||||||
|
"""Test setting address as default."""
|
||||||
|
# Create a second non-default address
|
||||||
|
second_address = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=shop_customer.id,
|
||||||
|
address_type="shipping",
|
||||||
|
first_name="Second",
|
||||||
|
last_name="Address",
|
||||||
|
address_line_1="222 Second St",
|
||||||
|
city="Dudelange",
|
||||||
|
postal_code="L-3333",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=False,
|
||||||
|
)
|
||||||
|
db.add(second_address)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(second_address)
|
||||||
|
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/shop/addresses/{second_address.id}/default",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["is_default"] is True
|
||||||
|
|
||||||
|
def test_set_default_not_found(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test setting default on non-existent address returns 404."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/shop/addresses/99999/default",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_set_default_other_customer(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
other_customer_address,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test cannot set default on another customer's address."""
|
||||||
|
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/shop/addresses/{other_customer_address.id}/default",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
557
tests/integration/api/v1/shop/test_orders.py
Normal file
557
tests/integration/api/v1/shop/test_orders.py
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
# tests/integration/api/v1/shop/test_orders.py
|
||||||
|
"""Integration tests for shop orders API endpoints.
|
||||||
|
|
||||||
|
Tests the /api/v1/shop/orders/* endpoints.
|
||||||
|
All endpoints require customer JWT authentication with vendor context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from models.database.customer import Customer
|
||||||
|
from models.database.invoice import Invoice, InvoiceStatus, VendorInvoiceSettings
|
||||||
|
from models.database.order import Order, OrderItem
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_customer(db, test_vendor):
|
||||||
|
"""Create a test customer for shop API tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
customer = Customer(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
email="shopcustomer@example.com",
|
||||||
|
hashed_password=auth_manager.hash_password("testpass123"),
|
||||||
|
first_name="Shop",
|
||||||
|
last_name="Customer",
|
||||||
|
customer_number="SHOP001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(customer)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_customer_token(shop_customer, test_vendor):
|
||||||
|
"""Create JWT token for shop customer."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
|
||||||
|
expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
|
||||||
|
expire = datetime.now(UTC) + expires_delta
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"sub": str(shop_customer.id),
|
||||||
|
"email": shop_customer.email,
|
||||||
|
"vendor_id": test_vendor.id,
|
||||||
|
"type": "customer",
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
payload, auth_manager.secret_key, algorithm=auth_manager.algorithm
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_customer_headers(shop_customer_token):
|
||||||
|
"""Get authentication headers for shop customer."""
|
||||||
|
return {"Authorization": f"Bearer {shop_customer_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_order(db, test_vendor, shop_customer):
|
||||||
|
"""Create a test order for shop customer."""
|
||||||
|
order = Order(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=shop_customer.id,
|
||||||
|
order_number="SHOP-ORD-001",
|
||||||
|
status="pending",
|
||||||
|
channel="direct",
|
||||||
|
order_date=datetime.now(UTC),
|
||||||
|
subtotal_cents=10000,
|
||||||
|
tax_amount_cents=1700,
|
||||||
|
shipping_amount_cents=500,
|
||||||
|
total_amount_cents=12200,
|
||||||
|
currency="EUR",
|
||||||
|
customer_email=shop_customer.email,
|
||||||
|
customer_first_name=shop_customer.first_name,
|
||||||
|
customer_last_name=shop_customer.last_name,
|
||||||
|
ship_first_name=shop_customer.first_name,
|
||||||
|
ship_last_name=shop_customer.last_name,
|
||||||
|
ship_address_line_1="123 Shop St",
|
||||||
|
ship_city="Luxembourg",
|
||||||
|
ship_postal_code="L-1234",
|
||||||
|
ship_country_iso="LU",
|
||||||
|
bill_first_name=shop_customer.first_name,
|
||||||
|
bill_last_name=shop_customer.last_name,
|
||||||
|
bill_address_line_1="123 Shop St",
|
||||||
|
bill_city="Luxembourg",
|
||||||
|
bill_postal_code="L-1234",
|
||||||
|
bill_country_iso="LU",
|
||||||
|
# VAT fields
|
||||||
|
vat_regime="domestic",
|
||||||
|
vat_rate=Decimal("17.00"),
|
||||||
|
vat_rate_label="Luxembourg VAT 17.00%",
|
||||||
|
vat_destination_country=None,
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_order_processing(db, test_vendor, shop_customer):
|
||||||
|
"""Create a test order with processing status (eligible for invoice)."""
|
||||||
|
order = Order(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=shop_customer.id,
|
||||||
|
order_number="SHOP-ORD-002",
|
||||||
|
status="processing",
|
||||||
|
channel="direct",
|
||||||
|
order_date=datetime.now(UTC),
|
||||||
|
subtotal_cents=20000,
|
||||||
|
tax_amount_cents=3400,
|
||||||
|
shipping_amount_cents=500,
|
||||||
|
total_amount_cents=23900,
|
||||||
|
currency="EUR",
|
||||||
|
customer_email=shop_customer.email,
|
||||||
|
customer_first_name=shop_customer.first_name,
|
||||||
|
customer_last_name=shop_customer.last_name,
|
||||||
|
ship_first_name=shop_customer.first_name,
|
||||||
|
ship_last_name=shop_customer.last_name,
|
||||||
|
ship_address_line_1="456 Shop Ave",
|
||||||
|
ship_city="Luxembourg",
|
||||||
|
ship_postal_code="L-5678",
|
||||||
|
ship_country_iso="LU",
|
||||||
|
bill_first_name=shop_customer.first_name,
|
||||||
|
bill_last_name=shop_customer.last_name,
|
||||||
|
bill_address_line_1="456 Shop Ave",
|
||||||
|
bill_city="Luxembourg",
|
||||||
|
bill_postal_code="L-5678",
|
||||||
|
bill_country_iso="LU",
|
||||||
|
# VAT fields
|
||||||
|
vat_regime="domestic",
|
||||||
|
vat_rate=Decimal("17.00"),
|
||||||
|
vat_rate_label="Luxembourg VAT 17.00%",
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Add order item
|
||||||
|
item = OrderItem(
|
||||||
|
order_id=order.id,
|
||||||
|
product_id=1,
|
||||||
|
product_sku="TEST-SKU-001",
|
||||||
|
product_name="Test Product",
|
||||||
|
quantity=2,
|
||||||
|
unit_price_cents=10000,
|
||||||
|
total_price_cents=20000,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_invoice_settings(db, test_vendor):
|
||||||
|
"""Create invoice settings for the vendor."""
|
||||||
|
settings = VendorInvoiceSettings(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
company_name="Shop Test Company S.A.",
|
||||||
|
company_address="123 Business St",
|
||||||
|
company_city="Luxembourg",
|
||||||
|
company_postal_code="L-1234",
|
||||||
|
company_country="LU",
|
||||||
|
vat_number="LU12345678",
|
||||||
|
invoice_prefix="INV",
|
||||||
|
invoice_next_number=1,
|
||||||
|
default_vat_rate=Decimal("17.00"),
|
||||||
|
)
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shop_order_with_invoice(db, test_vendor, shop_customer, shop_invoice_settings):
|
||||||
|
"""Create an order with an existing invoice."""
|
||||||
|
order = Order(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=shop_customer.id,
|
||||||
|
order_number="SHOP-ORD-003",
|
||||||
|
status="shipped",
|
||||||
|
channel="direct",
|
||||||
|
order_date=datetime.now(UTC),
|
||||||
|
subtotal_cents=15000,
|
||||||
|
tax_amount_cents=2550,
|
||||||
|
shipping_amount_cents=500,
|
||||||
|
total_amount_cents=18050,
|
||||||
|
currency="EUR",
|
||||||
|
customer_email=shop_customer.email,
|
||||||
|
customer_first_name=shop_customer.first_name,
|
||||||
|
customer_last_name=shop_customer.last_name,
|
||||||
|
ship_first_name=shop_customer.first_name,
|
||||||
|
ship_last_name=shop_customer.last_name,
|
||||||
|
ship_address_line_1="789 Shop Blvd",
|
||||||
|
ship_city="Luxembourg",
|
||||||
|
ship_postal_code="L-9999",
|
||||||
|
ship_country_iso="LU",
|
||||||
|
bill_first_name=shop_customer.first_name,
|
||||||
|
bill_last_name=shop_customer.last_name,
|
||||||
|
bill_address_line_1="789 Shop Blvd",
|
||||||
|
bill_city="Luxembourg",
|
||||||
|
bill_postal_code="L-9999",
|
||||||
|
bill_country_iso="LU",
|
||||||
|
vat_regime="domestic",
|
||||||
|
vat_rate=Decimal("17.00"),
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Create invoice for this order
|
||||||
|
invoice = Invoice(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
order_id=order.id,
|
||||||
|
invoice_number="INV00001",
|
||||||
|
invoice_date=datetime.now(UTC),
|
||||||
|
status=InvoiceStatus.ISSUED.value,
|
||||||
|
seller_details={"company_name": "Shop Test Company S.A."},
|
||||||
|
buyer_details={"name": f"{shop_customer.first_name} {shop_customer.last_name}"},
|
||||||
|
line_items=[],
|
||||||
|
vat_rate=Decimal("17.00"),
|
||||||
|
subtotal_cents=15000,
|
||||||
|
vat_amount_cents=2550,
|
||||||
|
total_cents=18050,
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
db.refresh(invoice)
|
||||||
|
return order, invoice
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_customer(db, test_vendor):
|
||||||
|
"""Create another customer for testing access controls."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
auth_manager = AuthManager()
|
||||||
|
customer = Customer(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
email="othercustomer@example.com",
|
||||||
|
hashed_password=auth_manager.hash_password("otherpass123"),
|
||||||
|
first_name="Other",
|
||||||
|
last_name="Customer",
|
||||||
|
customer_number="OTHER001",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(customer)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_customer_order(db, test_vendor, other_customer):
|
||||||
|
"""Create an order for another customer."""
|
||||||
|
order = Order(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=other_customer.id,
|
||||||
|
order_number="OTHER-ORD-001",
|
||||||
|
status="processing",
|
||||||
|
channel="direct",
|
||||||
|
order_date=datetime.now(UTC),
|
||||||
|
subtotal_cents=5000,
|
||||||
|
tax_amount_cents=850,
|
||||||
|
total_amount_cents=5850,
|
||||||
|
currency="EUR",
|
||||||
|
customer_email=other_customer.email,
|
||||||
|
customer_first_name=other_customer.first_name,
|
||||||
|
customer_last_name=other_customer.last_name,
|
||||||
|
ship_first_name=other_customer.first_name,
|
||||||
|
ship_last_name=other_customer.last_name,
|
||||||
|
ship_address_line_1="Other St",
|
||||||
|
ship_city="Other City",
|
||||||
|
ship_postal_code="00000",
|
||||||
|
ship_country_iso="LU",
|
||||||
|
bill_first_name=other_customer.first_name,
|
||||||
|
bill_last_name=other_customer.last_name,
|
||||||
|
bill_address_line_1="Other St",
|
||||||
|
bill_city="Other City",
|
||||||
|
bill_postal_code="00000",
|
||||||
|
bill_country_iso="LU",
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
# Note: Shop API endpoints require vendor context from VendorContextMiddleware.
|
||||||
|
# In integration tests, we mock the middleware to inject the vendor.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopOrdersListAPI:
|
||||||
|
"""Test shop orders list endpoint at /api/v1/shop/orders."""
|
||||||
|
|
||||||
|
def test_list_orders_requires_authentication(self, client, test_vendor):
|
||||||
|
"""Test that listing orders requires authentication."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
response = client.get("/api/v1/shop/orders")
|
||||||
|
# Without token, should get 401 or 403
|
||||||
|
assert response.status_code in [401, 403]
|
||||||
|
|
||||||
|
def test_list_orders_success(
|
||||||
|
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test listing customer orders successfully."""
|
||||||
|
# Mock vendor context and customer auth
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
# Mock the dependency to return our customer
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/orders",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "orders" in data
|
||||||
|
assert "total" in data
|
||||||
|
|
||||||
|
def test_list_orders_empty(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test listing orders when customer has none."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/orders",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopOrderDetailAPI:
|
||||||
|
"""Test shop order detail endpoint at /api/v1/shop/orders/{order_id}."""
|
||||||
|
|
||||||
|
def test_get_order_detail_success(
|
||||||
|
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test getting order details successfully."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/shop/orders/{shop_order.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["order_number"] == "SHOP-ORD-001"
|
||||||
|
assert data["status"] == "pending"
|
||||||
|
# Check VAT fields are present
|
||||||
|
assert "vat_regime" in data
|
||||||
|
assert "vat_rate" in data
|
||||||
|
|
||||||
|
def test_get_order_detail_not_found(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test getting non-existent order returns 404."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/orders/99999",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_get_order_detail_other_customer(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
other_customer_order,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test cannot access another customer's order."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/shop/orders/{other_customer_order.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 404 (not 403) to prevent enumeration
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopOrderInvoiceDownloadAPI:
|
||||||
|
"""Test shop order invoice download at /api/v1/shop/orders/{order_id}/invoice."""
|
||||||
|
|
||||||
|
def test_download_invoice_pending_order_rejected(
|
||||||
|
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test cannot download invoice for pending orders."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/shop/orders/{shop_order.id}/invoice",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pending orders should not allow invoice download
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Requires PDF generation infrastructure")
|
||||||
|
def test_download_invoice_processing_order_creates_invoice(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
shop_order_processing,
|
||||||
|
shop_invoice_settings,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test downloading invoice for processing order creates it if needed."""
|
||||||
|
# This test requires actual PDF generation which may not be available
|
||||||
|
# in all environments. The logic is tested via:
|
||||||
|
# 1. test_download_invoice_pending_order_rejected - validates status check
|
||||||
|
# 2. Direct service tests for invoice creation
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Requires PDF generation infrastructure")
|
||||||
|
def test_download_invoice_existing_invoice(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
shop_order_with_invoice,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test downloading invoice when one already exists."""
|
||||||
|
# This test requires PDF file to exist on disk
|
||||||
|
# The service layer handles invoice retrieval properly
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_download_invoice_other_customer(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
shop_customer_headers,
|
||||||
|
other_customer_order,
|
||||||
|
test_vendor,
|
||||||
|
shop_customer,
|
||||||
|
):
|
||||||
|
"""Test cannot download invoice for another customer's order."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/shop/orders/{other_customer_order.id}/invoice",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return 404 to prevent enumeration
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_download_invoice_not_found(
|
||||||
|
self, client, shop_customer_headers, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test downloading invoice for non-existent order."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/orders/99999/invoice",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.api
|
||||||
|
@pytest.mark.shop
|
||||||
|
class TestShopOrderVATFields:
|
||||||
|
"""Test VAT fields in order responses."""
|
||||||
|
|
||||||
|
def test_order_includes_vat_fields(
|
||||||
|
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test order response includes VAT fields."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/shop/orders/{shop_order.id}",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify VAT fields
|
||||||
|
assert data.get("vat_regime") == "domestic"
|
||||||
|
assert data.get("vat_rate") == 17.0
|
||||||
|
assert "Luxembourg VAT" in (data.get("vat_rate_label") or "")
|
||||||
|
|
||||||
|
def test_order_list_includes_vat_fields(
|
||||||
|
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
|
||||||
|
):
|
||||||
|
"""Test order list includes VAT fields."""
|
||||||
|
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
|
||||||
|
mock_getattr.return_value = test_vendor
|
||||||
|
with patch("app.api.deps._validate_customer_token") as mock_validate:
|
||||||
|
mock_validate.return_value = shop_customer
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/shop/orders",
|
||||||
|
headers=shop_customer_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data["orders"]:
|
||||||
|
order = data["orders"][0]
|
||||||
|
assert "vat_regime" in order
|
||||||
|
assert "vat_rate" in order
|
||||||
453
tests/unit/services/test_customer_address_service.py
Normal file
453
tests/unit/services/test_customer_address_service.py
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
# tests/unit/services/test_customer_address_service.py
|
||||||
|
"""
|
||||||
|
Unit tests for CustomerAddressService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.exceptions import AddressLimitExceededException, AddressNotFoundException
|
||||||
|
from app.services.customer_address_service import CustomerAddressService
|
||||||
|
from models.database.customer import CustomerAddress
|
||||||
|
from models.schema.customer import CustomerAddressCreate, CustomerAddressUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def address_service():
|
||||||
|
"""Create CustomerAddressService instance."""
|
||||||
|
return CustomerAddressService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def multiple_addresses(db, test_vendor, test_customer):
|
||||||
|
"""Create multiple addresses for testing."""
|
||||||
|
addresses = []
|
||||||
|
for i in range(3):
|
||||||
|
address = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_type="shipping" if i < 2 else "billing",
|
||||||
|
first_name=f"First{i}",
|
||||||
|
last_name=f"Last{i}",
|
||||||
|
address_line_1=f"{i+1} Test Street",
|
||||||
|
city="Luxembourg",
|
||||||
|
postal_code=f"L-{1000+i}",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=(i == 0), # First shipping is default
|
||||||
|
)
|
||||||
|
db.add(address)
|
||||||
|
addresses.append(address)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
for a in addresses:
|
||||||
|
db.refresh(a)
|
||||||
|
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerAddressServiceList:
|
||||||
|
"""Tests for list_addresses method."""
|
||||||
|
|
||||||
|
def test_list_addresses_empty(self, db, address_service, test_vendor, test_customer):
|
||||||
|
"""Test listing addresses when none exist."""
|
||||||
|
addresses = address_service.list_addresses(
|
||||||
|
db, vendor_id=test_vendor.id, customer_id=test_customer.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addresses == []
|
||||||
|
|
||||||
|
def test_list_addresses_basic(
|
||||||
|
self, db, address_service, test_vendor, test_customer, test_customer_address
|
||||||
|
):
|
||||||
|
"""Test basic address listing."""
|
||||||
|
addresses = address_service.list_addresses(
|
||||||
|
db, vendor_id=test_vendor.id, customer_id=test_customer.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(addresses) == 1
|
||||||
|
assert addresses[0].id == test_customer_address.id
|
||||||
|
|
||||||
|
def test_list_addresses_ordered_by_default(
|
||||||
|
self, db, address_service, test_vendor, test_customer, multiple_addresses
|
||||||
|
):
|
||||||
|
"""Test addresses are ordered by default flag first."""
|
||||||
|
addresses = address_service.list_addresses(
|
||||||
|
db, vendor_id=test_vendor.id, customer_id=test_customer.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default address should be first
|
||||||
|
assert addresses[0].is_default is True
|
||||||
|
|
||||||
|
def test_list_addresses_vendor_isolation(
|
||||||
|
self, db, address_service, test_vendor, test_customer, test_customer_address
|
||||||
|
):
|
||||||
|
"""Test addresses are isolated by vendor."""
|
||||||
|
# Query with different vendor ID
|
||||||
|
addresses = address_service.list_addresses(
|
||||||
|
db, vendor_id=99999, customer_id=test_customer.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert addresses == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerAddressServiceGet:
|
||||||
|
"""Tests for get_address method."""
|
||||||
|
|
||||||
|
def test_get_address_success(
|
||||||
|
self, db, address_service, test_vendor, test_customer, test_customer_address
|
||||||
|
):
|
||||||
|
"""Test getting address by ID."""
|
||||||
|
address = address_service.get_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=test_customer_address.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert address.id == test_customer_address.id
|
||||||
|
assert address.first_name == test_customer_address.first_name
|
||||||
|
|
||||||
|
def test_get_address_not_found(
|
||||||
|
self, db, address_service, test_vendor, test_customer
|
||||||
|
):
|
||||||
|
"""Test error when address not found."""
|
||||||
|
with pytest.raises(AddressNotFoundException):
|
||||||
|
address_service.get_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=99999,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_address_wrong_customer(
|
||||||
|
self, db, address_service, test_vendor, test_customer, test_customer_address
|
||||||
|
):
|
||||||
|
"""Test cannot get another customer's address."""
|
||||||
|
with pytest.raises(AddressNotFoundException):
|
||||||
|
address_service.get_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=99999, # Different customer
|
||||||
|
address_id=test_customer_address.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerAddressServiceGetDefault:
|
||||||
|
"""Tests for get_default_address method."""
|
||||||
|
|
||||||
|
def test_get_default_address_exists(
|
||||||
|
self, db, address_service, test_vendor, test_customer, multiple_addresses
|
||||||
|
):
|
||||||
|
"""Test getting default shipping address."""
|
||||||
|
address = address_service.get_default_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_type="shipping",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert address is not None
|
||||||
|
assert address.is_default is True
|
||||||
|
assert address.address_type == "shipping"
|
||||||
|
|
||||||
|
def test_get_default_address_not_set(
|
||||||
|
self, db, address_service, test_vendor, test_customer, multiple_addresses
|
||||||
|
):
|
||||||
|
"""Test getting default billing when none is set."""
|
||||||
|
# Remove default from billing (none was set as default)
|
||||||
|
address = address_service.get_default_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_type="billing",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The billing address exists but is not default
|
||||||
|
assert address is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerAddressServiceCreate:
|
||||||
|
"""Tests for create_address method."""
|
||||||
|
|
||||||
|
def test_create_address_success(
|
||||||
|
self, db, address_service, test_vendor, test_customer
|
||||||
|
):
|
||||||
|
"""Test creating a new address."""
|
||||||
|
address_data = CustomerAddressCreate(
|
||||||
|
address_type="shipping",
|
||||||
|
first_name="John",
|
||||||
|
last_name="Doe",
|
||||||
|
address_line_1="123 New Street",
|
||||||
|
city="Luxembourg",
|
||||||
|
postal_code="L-1234",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
address = address_service.create_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_data=address_data,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert address.id is not None
|
||||||
|
assert address.first_name == "John"
|
||||||
|
assert address.last_name == "Doe"
|
||||||
|
assert address.country_iso == "LU"
|
||||||
|
assert address.country_name == "Luxembourg"
|
||||||
|
|
||||||
|
def test_create_address_with_company(
|
||||||
|
self, db, address_service, test_vendor, test_customer
|
||||||
|
):
|
||||||
|
"""Test creating address with company name."""
|
||||||
|
address_data = CustomerAddressCreate(
|
||||||
|
address_type="billing",
|
||||||
|
first_name="Jane",
|
||||||
|
last_name="Doe",
|
||||||
|
company="Acme Corp",
|
||||||
|
address_line_1="456 Business Ave",
|
||||||
|
city="Luxembourg",
|
||||||
|
postal_code="L-5678",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
address = address_service.create_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_data=address_data,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert address.company == "Acme Corp"
|
||||||
|
|
||||||
|
def test_create_address_default_clears_others(
|
||||||
|
self, db, address_service, test_vendor, test_customer, multiple_addresses
|
||||||
|
):
|
||||||
|
"""Test creating default address clears other defaults of same type."""
|
||||||
|
# First address is default shipping
|
||||||
|
assert multiple_addresses[0].is_default is True
|
||||||
|
|
||||||
|
address_data = CustomerAddressCreate(
|
||||||
|
address_type="shipping",
|
||||||
|
first_name="New",
|
||||||
|
last_name="Default",
|
||||||
|
address_line_1="789 Main St",
|
||||||
|
city="Luxembourg",
|
||||||
|
postal_code="L-9999",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_address = address_service.create_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_data=address_data,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# New address should be default
|
||||||
|
assert new_address.is_default is True
|
||||||
|
|
||||||
|
# Old default should be cleared
|
||||||
|
db.refresh(multiple_addresses[0])
|
||||||
|
assert multiple_addresses[0].is_default is False
|
||||||
|
|
||||||
|
def test_create_address_limit_exceeded(
|
||||||
|
self, db, address_service, test_vendor, test_customer
|
||||||
|
):
|
||||||
|
"""Test error when max addresses reached."""
|
||||||
|
# Create 10 addresses (max limit)
|
||||||
|
for i in range(10):
|
||||||
|
addr = CustomerAddress(
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_type="shipping",
|
||||||
|
first_name=f"Test{i}",
|
||||||
|
last_name="User",
|
||||||
|
address_line_1=f"{i} Street",
|
||||||
|
city="City",
|
||||||
|
postal_code="12345",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
)
|
||||||
|
db.add(addr)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Try to create 11th address
|
||||||
|
address_data = CustomerAddressCreate(
|
||||||
|
address_type="shipping",
|
||||||
|
first_name="Eleventh",
|
||||||
|
last_name="User",
|
||||||
|
address_line_1="11 Street",
|
||||||
|
city="City",
|
||||||
|
postal_code="12345",
|
||||||
|
country_name="Luxembourg",
|
||||||
|
country_iso="LU",
|
||||||
|
is_default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(AddressLimitExceededException):
|
||||||
|
address_service.create_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_data=address_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerAddressServiceUpdate:
|
||||||
|
"""Tests for update_address method."""
|
||||||
|
|
||||||
|
def test_update_address_success(
|
||||||
|
self, db, address_service, test_vendor, test_customer, test_customer_address
|
||||||
|
):
|
||||||
|
"""Test updating an address."""
|
||||||
|
update_data = CustomerAddressUpdate(
|
||||||
|
first_name="Updated",
|
||||||
|
city="New City",
|
||||||
|
)
|
||||||
|
|
||||||
|
address = address_service.update_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=test_customer_address.id,
|
||||||
|
address_data=update_data,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert address.first_name == "Updated"
|
||||||
|
assert address.city == "New City"
|
||||||
|
# Unchanged fields should remain
|
||||||
|
assert address.last_name == test_customer_address.last_name
|
||||||
|
|
||||||
|
def test_update_address_set_default(
|
||||||
|
self, db, address_service, test_vendor, test_customer, multiple_addresses
|
||||||
|
):
|
||||||
|
"""Test setting address as default clears others."""
|
||||||
|
# Second address is not default
|
||||||
|
assert multiple_addresses[1].is_default is False
|
||||||
|
|
||||||
|
update_data = CustomerAddressUpdate(is_default=True)
|
||||||
|
|
||||||
|
address = address_service.update_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=multiple_addresses[1].id,
|
||||||
|
address_data=update_data,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert address.is_default is True
|
||||||
|
|
||||||
|
# Old default should be cleared
|
||||||
|
db.refresh(multiple_addresses[0])
|
||||||
|
assert multiple_addresses[0].is_default is False
|
||||||
|
|
||||||
|
def test_update_address_not_found(
|
||||||
|
self, db, address_service, test_vendor, test_customer
|
||||||
|
):
|
||||||
|
"""Test error when address not found."""
|
||||||
|
update_data = CustomerAddressUpdate(first_name="Test")
|
||||||
|
|
||||||
|
with pytest.raises(AddressNotFoundException):
|
||||||
|
address_service.update_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=99999,
|
||||||
|
address_data=update_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerAddressServiceDelete:
|
||||||
|
"""Tests for delete_address method."""
|
||||||
|
|
||||||
|
def test_delete_address_success(
|
||||||
|
self, db, address_service, test_vendor, test_customer, test_customer_address
|
||||||
|
):
|
||||||
|
"""Test deleting an address."""
|
||||||
|
address_id = test_customer_address.id
|
||||||
|
|
||||||
|
address_service.delete_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=address_id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Address should be gone
|
||||||
|
with pytest.raises(AddressNotFoundException):
|
||||||
|
address_service.get_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=address_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_address_not_found(
|
||||||
|
self, db, address_service, test_vendor, test_customer
|
||||||
|
):
|
||||||
|
"""Test error when deleting non-existent address."""
|
||||||
|
with pytest.raises(AddressNotFoundException):
|
||||||
|
address_service.delete_address(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=99999,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerAddressServiceSetDefault:
|
||||||
|
"""Tests for set_default method."""
|
||||||
|
|
||||||
|
def test_set_default_success(
|
||||||
|
self, db, address_service, test_vendor, test_customer, multiple_addresses
|
||||||
|
):
|
||||||
|
"""Test setting address as default."""
|
||||||
|
# Second shipping address is not default
|
||||||
|
assert multiple_addresses[1].is_default is False
|
||||||
|
assert multiple_addresses[1].address_type == "shipping"
|
||||||
|
|
||||||
|
address = address_service.set_default(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=multiple_addresses[1].id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert address.is_default is True
|
||||||
|
|
||||||
|
# Old default should be cleared
|
||||||
|
db.refresh(multiple_addresses[0])
|
||||||
|
assert multiple_addresses[0].is_default is False
|
||||||
|
|
||||||
|
def test_set_default_not_found(
|
||||||
|
self, db, address_service, test_vendor, test_customer
|
||||||
|
):
|
||||||
|
"""Test error when address not found."""
|
||||||
|
with pytest.raises(AddressNotFoundException):
|
||||||
|
address_service.set_default(
|
||||||
|
db,
|
||||||
|
vendor_id=test_vendor.id,
|
||||||
|
customer_id=test_customer.id,
|
||||||
|
address_id=99999,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user