887 lines
26 KiB
Markdown
887 lines
26 KiB
Markdown
# Slice 4: Customer Shopping Experience
|
|
## Customers Browse and Shop on Vendor Stores
|
|
|
|
**Status**: 📋 NOT STARTED
|
|
**Timeline**: Week 4 (5 days)
|
|
**Prerequisites**: Slices 1, 2, & 3 complete
|
|
|
|
## 🎯 Slice Objectives
|
|
|
|
Build the public-facing customer shop where customers can browse products, register accounts, and add items to cart.
|
|
|
|
### User Stories
|
|
- As a Customer, I can browse products on a vendor's shop
|
|
- As a Customer, I can view detailed product information
|
|
- As a Customer, I can search for products
|
|
- As a Customer, I can register for a vendor-specific account
|
|
- As a Customer, I can log into my account
|
|
- As a Customer, I can add products to my shopping cart
|
|
- As a Customer, I can manage my cart (update quantities, remove items)
|
|
- Cart persists across sessions
|
|
|
|
### Success Criteria
|
|
- [ ] Customers can browse products without authentication
|
|
- [ ] Product catalog displays correctly with images and prices
|
|
- [ ] Product detail pages show complete information
|
|
- [ ] Search functionality works
|
|
- [ ] Customers can register vendor-specific accounts
|
|
- [ ] Customer login/logout works
|
|
- [ ] Shopping cart is functional with Alpine.js reactivity
|
|
- [ ] Cart persists (session-based before login, user-based after)
|
|
- [ ] Customer data is properly isolated by vendor
|
|
- [ ] Mobile responsive design
|
|
|
|
## 📋 Backend Implementation
|
|
|
|
### Database Models
|
|
|
|
#### Customer Model (`models/database/customer.py`)
|
|
|
|
```python
|
|
class Customer(Base, TimestampMixin):
|
|
"""
|
|
Vendor-scoped customer accounts
|
|
Each customer belongs to ONE vendor
|
|
"""
|
|
__tablename__ = "customers"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
|
|
# Authentication
|
|
email = Column(String, nullable=False, index=True)
|
|
hashed_password = Column(String, nullable=False)
|
|
|
|
# Personal information
|
|
first_name = Column(String)
|
|
last_name = Column(String)
|
|
phone = Column(String)
|
|
|
|
# Customer metadata
|
|
customer_number = Column(String, unique=True, index=True) # Auto-generated
|
|
|
|
# Preferences
|
|
language = Column(String(2), default="en")
|
|
newsletter_subscribed = Column(Boolean, default=False)
|
|
marketing_emails = Column(Boolean, default=True)
|
|
preferences = Column(JSON, default=dict)
|
|
|
|
# Statistics
|
|
total_orders = Column(Integer, default=0)
|
|
total_spent = Column(Numeric(10, 2), default=0)
|
|
|
|
# Status
|
|
is_active = Column(Boolean, default=True)
|
|
email_verified = Column(Boolean, default=False)
|
|
last_login_at = Column(DateTime, nullable=True)
|
|
|
|
# Relationships
|
|
vendor = relationship("Vendor", back_populates="customers")
|
|
addresses = relationship("CustomerAddress", back_populates="customer", cascade="all, delete-orphan")
|
|
orders = relationship("Order", back_populates="customer")
|
|
cart = relationship("Cart", back_populates="customer", uselist=False)
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index('ix_customer_vendor_email', 'vendor_id', 'email', unique=True),
|
|
)
|
|
|
|
class CustomerAddress(Base, TimestampMixin):
|
|
"""Customer shipping/billing addresses"""
|
|
__tablename__ = "customer_addresses"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
|
|
|
|
# Address type
|
|
address_type = Column(String, default="shipping") # shipping, billing, both
|
|
is_default = Column(Boolean, default=False)
|
|
|
|
# Address details
|
|
first_name = Column(String)
|
|
last_name = Column(String)
|
|
company = Column(String)
|
|
address_line1 = Column(String, nullable=False)
|
|
address_line2 = Column(String)
|
|
city = Column(String, nullable=False)
|
|
state_province = Column(String)
|
|
postal_code = Column(String, nullable=False)
|
|
country = Column(String, nullable=False, default="LU")
|
|
phone = Column(String)
|
|
|
|
# Relationships
|
|
customer = relationship("Customer", back_populates="addresses")
|
|
```
|
|
|
|
#### Cart Model (`models/database/cart.py`)
|
|
|
|
```python
|
|
class Cart(Base, TimestampMixin):
|
|
"""Shopping cart - session or customer-based"""
|
|
__tablename__ = "carts"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
|
|
# Owner (one of these must be set)
|
|
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
|
|
session_id = Column(String, nullable=True, index=True) # For guest users
|
|
|
|
# Cart metadata
|
|
currency = Column(String(3), default="EUR")
|
|
|
|
# Relationships
|
|
vendor = relationship("Vendor")
|
|
customer = relationship("Customer", back_populates="cart")
|
|
items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
|
|
|
|
# Computed properties
|
|
@property
|
|
def total_items(self) -> int:
|
|
return sum(item.quantity for item in self.items)
|
|
|
|
@property
|
|
def subtotal(self) -> Decimal:
|
|
return sum(item.line_total for item in self.items)
|
|
|
|
__table_args__ = (
|
|
Index('ix_cart_vendor_session', 'vendor_id', 'session_id'),
|
|
Index('ix_cart_vendor_customer', 'vendor_id', 'customer_id'),
|
|
)
|
|
|
|
class CartItem(Base, TimestampMixin):
|
|
"""Individual items in cart"""
|
|
__tablename__ = "cart_items"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
cart_id = Column(Integer, ForeignKey("carts.id"), nullable=False)
|
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
|
|
|
# Item details
|
|
quantity = Column(Integer, nullable=False, default=1)
|
|
unit_price = Column(Numeric(10, 2), nullable=False) # Snapshot at time of add
|
|
|
|
# Relationships
|
|
cart = relationship("Cart", back_populates="items")
|
|
product = relationship("Product")
|
|
|
|
@property
|
|
def line_total(self) -> Decimal:
|
|
return self.unit_price * self.quantity
|
|
```
|
|
|
|
### Pydantic Schemas
|
|
|
|
#### Customer Schemas (`models/schema/customer.py`)
|
|
|
|
```python
|
|
class CustomerRegister(BaseModel):
|
|
"""Customer registration"""
|
|
email: EmailStr
|
|
password: str = Field(..., min_length=8)
|
|
first_name: str
|
|
last_name: str
|
|
phone: Optional[str] = None
|
|
newsletter_subscribed: bool = False
|
|
|
|
class CustomerLogin(BaseModel):
|
|
"""Customer login"""
|
|
email: EmailStr
|
|
password: str
|
|
|
|
class CustomerResponse(BaseModel):
|
|
"""Customer details"""
|
|
id: int
|
|
vendor_id: int
|
|
email: str
|
|
first_name: str
|
|
last_name: str
|
|
phone: Optional[str]
|
|
customer_number: str
|
|
total_orders: int
|
|
total_spent: float
|
|
is_active: bool
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
class CustomerAddressCreate(BaseModel):
|
|
"""Create address"""
|
|
address_type: str = "shipping"
|
|
is_default: bool = False
|
|
first_name: str
|
|
last_name: str
|
|
company: Optional[str] = None
|
|
address_line1: str
|
|
address_line2: Optional[str] = None
|
|
city: str
|
|
state_province: Optional[str] = None
|
|
postal_code: str
|
|
country: str = "LU"
|
|
phone: Optional[str] = None
|
|
|
|
class CustomerAddressResponse(BaseModel):
|
|
"""Address details"""
|
|
id: int
|
|
address_type: str
|
|
is_default: bool
|
|
first_name: str
|
|
last_name: str
|
|
company: Optional[str]
|
|
address_line1: str
|
|
address_line2: Optional[str]
|
|
city: str
|
|
state_province: Optional[str]
|
|
postal_code: str
|
|
country: str
|
|
phone: Optional[str]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
```
|
|
|
|
#### Cart Schemas (`models/schema/cart.py`)
|
|
|
|
```python
|
|
class CartItemAdd(BaseModel):
|
|
"""Add item to cart"""
|
|
product_id: int
|
|
quantity: int = Field(..., gt=0)
|
|
|
|
class CartItemUpdate(BaseModel):
|
|
"""Update cart item"""
|
|
quantity: int = Field(..., gt=0)
|
|
|
|
class CartItemResponse(BaseModel):
|
|
"""Cart item details"""
|
|
id: int
|
|
product_id: int
|
|
product_title: str
|
|
product_image: Optional[str]
|
|
product_sku: str
|
|
quantity: int
|
|
unit_price: float
|
|
line_total: float
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
class CartResponse(BaseModel):
|
|
"""Complete cart"""
|
|
id: int
|
|
vendor_id: int
|
|
total_items: int
|
|
subtotal: float
|
|
currency: str
|
|
items: List[CartItemResponse]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
```
|
|
|
|
### Service Layer
|
|
|
|
#### Customer Service (`app/services/customer_service.py`)
|
|
|
|
```python
|
|
class CustomerService:
|
|
"""Handle customer operations"""
|
|
|
|
def __init__(self):
|
|
self.auth_manager = AuthManager()
|
|
|
|
async def register_customer(
|
|
self,
|
|
vendor_id: int,
|
|
customer_data: CustomerRegister,
|
|
db: Session
|
|
) -> Customer:
|
|
"""Register new customer for vendor"""
|
|
|
|
# Check if email already exists for this vendor
|
|
existing = db.query(Customer).filter(
|
|
Customer.vendor_id == vendor_id,
|
|
Customer.email == customer_data.email
|
|
).first()
|
|
|
|
if existing:
|
|
raise CustomerAlreadyExistsError("Email already registered")
|
|
|
|
# Generate customer number
|
|
customer_number = self._generate_customer_number(vendor_id, db)
|
|
|
|
# Create customer
|
|
customer = Customer(
|
|
vendor_id=vendor_id,
|
|
email=customer_data.email,
|
|
hashed_password=self.auth_manager.hash_password(customer_data.password),
|
|
first_name=customer_data.first_name,
|
|
last_name=customer_data.last_name,
|
|
phone=customer_data.phone,
|
|
customer_number=customer_number,
|
|
newsletter_subscribed=customer_data.newsletter_subscribed,
|
|
is_active=True
|
|
)
|
|
|
|
db.add(customer)
|
|
db.commit()
|
|
db.refresh(customer)
|
|
|
|
return customer
|
|
|
|
async def authenticate_customer(
|
|
self,
|
|
vendor_id: int,
|
|
email: str,
|
|
password: str,
|
|
db: Session
|
|
) -> Tuple[Customer, str]:
|
|
"""Authenticate customer and return token"""
|
|
|
|
customer = db.query(Customer).filter(
|
|
Customer.vendor_id == vendor_id,
|
|
Customer.email == email
|
|
).first()
|
|
|
|
if not customer:
|
|
raise InvalidCredentialsError()
|
|
|
|
if not customer.is_active:
|
|
raise CustomerInactiveError()
|
|
|
|
if not self.auth_manager.verify_password(password, customer.hashed_password):
|
|
raise InvalidCredentialsError()
|
|
|
|
# Update last login
|
|
customer.last_login_at = datetime.utcnow()
|
|
db.commit()
|
|
|
|
# Generate JWT token
|
|
token = self.auth_manager.create_access_token({
|
|
"sub": str(customer.id),
|
|
"email": customer.email,
|
|
"vendor_id": vendor_id,
|
|
"type": "customer"
|
|
})
|
|
|
|
return customer, token
|
|
|
|
def _generate_customer_number(self, vendor_id: int, db: Session) -> str:
|
|
"""Generate unique customer number"""
|
|
# Format: VENDOR_CODE-YYYYMMDD-XXXX
|
|
from models.database.vendor import Vendor
|
|
|
|
vendor = db.query(Vendor).get(vendor_id)
|
|
date_str = datetime.utcnow().strftime("%Y%m%d")
|
|
|
|
# Count customers today
|
|
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0)
|
|
count = db.query(Customer).filter(
|
|
Customer.vendor_id == vendor_id,
|
|
Customer.created_at >= today_start
|
|
).count()
|
|
|
|
return f"{vendor.vendor_code}-{date_str}-{count+1:04d}"
|
|
```
|
|
|
|
#### Cart Service (`app/services/cart_service.py`)
|
|
|
|
```python
|
|
class CartService:
|
|
"""Handle shopping cart operations"""
|
|
|
|
async def get_or_create_cart(
|
|
self,
|
|
vendor_id: int,
|
|
db: Session,
|
|
customer_id: Optional[int] = None,
|
|
session_id: Optional[str] = None
|
|
) -> Cart:
|
|
"""Get existing cart or create new one"""
|
|
|
|
if customer_id:
|
|
cart = db.query(Cart).filter(
|
|
Cart.vendor_id == vendor_id,
|
|
Cart.customer_id == customer_id
|
|
).first()
|
|
else:
|
|
cart = db.query(Cart).filter(
|
|
Cart.vendor_id == vendor_id,
|
|
Cart.session_id == session_id
|
|
).first()
|
|
|
|
if not cart:
|
|
cart = Cart(
|
|
vendor_id=vendor_id,
|
|
customer_id=customer_id,
|
|
session_id=session_id
|
|
)
|
|
db.add(cart)
|
|
db.commit()
|
|
db.refresh(cart)
|
|
|
|
return cart
|
|
|
|
async def add_to_cart(
|
|
self,
|
|
cart: Cart,
|
|
product_id: int,
|
|
quantity: int,
|
|
db: Session
|
|
) -> CartItem:
|
|
"""Add product to cart"""
|
|
|
|
# Verify product exists and is active
|
|
product = db.query(Product).filter(
|
|
Product.id == product_id,
|
|
Product.vendor_id == cart.vendor_id,
|
|
Product.is_active == True
|
|
).first()
|
|
|
|
if not product:
|
|
raise ProductNotFoundError()
|
|
|
|
# Check if product already in cart
|
|
existing_item = db.query(CartItem).filter(
|
|
CartItem.cart_id == cart.id,
|
|
CartItem.product_id == product_id
|
|
).first()
|
|
|
|
if existing_item:
|
|
# Update quantity
|
|
existing_item.quantity += quantity
|
|
db.commit()
|
|
db.refresh(existing_item)
|
|
return existing_item
|
|
else:
|
|
# Add new item
|
|
cart_item = CartItem(
|
|
cart_id=cart.id,
|
|
product_id=product_id,
|
|
quantity=quantity,
|
|
unit_price=product.price
|
|
)
|
|
db.add(cart_item)
|
|
db.commit()
|
|
db.refresh(cart_item)
|
|
return cart_item
|
|
|
|
async def update_cart_item(
|
|
self,
|
|
cart_item_id: int,
|
|
quantity: int,
|
|
cart: Cart,
|
|
db: Session
|
|
) -> CartItem:
|
|
"""Update cart item quantity"""
|
|
|
|
cart_item = db.query(CartItem).filter(
|
|
CartItem.id == cart_item_id,
|
|
CartItem.cart_id == cart.id
|
|
).first()
|
|
|
|
if not cart_item:
|
|
raise CartItemNotFoundError()
|
|
|
|
cart_item.quantity = quantity
|
|
db.commit()
|
|
db.refresh(cart_item)
|
|
|
|
return cart_item
|
|
|
|
async def remove_from_cart(
|
|
self,
|
|
cart_item_id: int,
|
|
cart: Cart,
|
|
db: Session
|
|
):
|
|
"""Remove item from cart"""
|
|
|
|
cart_item = db.query(CartItem).filter(
|
|
CartItem.id == cart_item_id,
|
|
CartItem.cart_id == cart.id
|
|
).first()
|
|
|
|
if not cart_item:
|
|
raise CartItemNotFoundError()
|
|
|
|
db.delete(cart_item)
|
|
db.commit()
|
|
|
|
async def clear_cart(self, cart: Cart, db: Session):
|
|
"""Clear all items from cart"""
|
|
|
|
db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
|
|
db.commit()
|
|
|
|
async def merge_carts(
|
|
self,
|
|
session_cart_id: int,
|
|
customer_cart_id: int,
|
|
db: Session
|
|
):
|
|
"""Merge session cart into customer cart after login"""
|
|
|
|
session_cart = db.query(Cart).get(session_cart_id)
|
|
customer_cart = db.query(Cart).get(customer_cart_id)
|
|
|
|
if not session_cart or not customer_cart:
|
|
return
|
|
|
|
# Move items from session cart to customer cart
|
|
for item in session_cart.items:
|
|
# Check if product already in customer cart
|
|
existing = db.query(CartItem).filter(
|
|
CartItem.cart_id == customer_cart.id,
|
|
CartItem.product_id == item.product_id
|
|
).first()
|
|
|
|
if existing:
|
|
existing.quantity += item.quantity
|
|
else:
|
|
item.cart_id = customer_cart.id
|
|
|
|
# Delete session cart
|
|
db.delete(session_cart)
|
|
db.commit()
|
|
```
|
|
|
|
### API Endpoints
|
|
|
|
#### Public Product Endpoints (`app/api/v1/public/vendors/products.py`)
|
|
|
|
```python
|
|
@router.get("", response_model=List[ProductResponse])
|
|
async def get_public_products(
|
|
vendor_id: int,
|
|
category: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
min_price: Optional[float] = None,
|
|
max_price: Optional[float] = None,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get public product catalog (no auth required)"""
|
|
|
|
query = db.query(Product).filter(
|
|
Product.vendor_id == vendor_id,
|
|
Product.is_active == True
|
|
)
|
|
|
|
if category:
|
|
query = query.filter(Product.category == category)
|
|
|
|
if search:
|
|
query = query.filter(
|
|
or_(
|
|
Product.title.ilike(f"%{search}%"),
|
|
Product.description.ilike(f"%{search}%")
|
|
)
|
|
)
|
|
|
|
if min_price:
|
|
query = query.filter(Product.price >= min_price)
|
|
|
|
if max_price:
|
|
query = query.filter(Product.price <= max_price)
|
|
|
|
products = query.order_by(
|
|
Product.is_featured.desc(),
|
|
Product.created_at.desc()
|
|
).offset(skip).limit(limit).all()
|
|
|
|
return products
|
|
|
|
@router.get("/{product_id}", response_model=ProductResponse)
|
|
async def get_public_product(
|
|
vendor_id: int,
|
|
product_id: int,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get product details (no auth required)"""
|
|
|
|
product = db.query(Product).filter(
|
|
Product.id == product_id,
|
|
Product.vendor_id == vendor_id,
|
|
Product.is_active == True
|
|
).first()
|
|
|
|
if not product:
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
|
|
|
return product
|
|
|
|
@router.get("/search")
|
|
async def search_products(
|
|
vendor_id: int,
|
|
q: str,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Search products"""
|
|
# Implement search logic
|
|
pass
|
|
```
|
|
|
|
#### Customer Auth Endpoints (`app/api/v1/public/vendors/auth.py`)
|
|
|
|
```python
|
|
@router.post("/register", response_model=CustomerResponse)
|
|
async def register_customer(
|
|
vendor_id: int,
|
|
customer_data: CustomerRegister,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Register new customer"""
|
|
service = CustomerService()
|
|
customer = await service.register_customer(vendor_id, customer_data, db)
|
|
return customer
|
|
|
|
@router.post("/login")
|
|
async def login_customer(
|
|
vendor_id: int,
|
|
credentials: CustomerLogin,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Customer login"""
|
|
service = CustomerService()
|
|
customer, token = await service.authenticate_customer(
|
|
vendor_id, credentials.email, credentials.password, db
|
|
)
|
|
|
|
return {
|
|
"access_token": token,
|
|
"token_type": "bearer",
|
|
"customer": CustomerResponse.from_orm(customer)
|
|
}
|
|
```
|
|
|
|
#### Cart Endpoints (`app/api/v1/public/vendors/cart.py`)
|
|
|
|
```python
|
|
@router.get("/{session_id}", response_model=CartResponse)
|
|
async def get_cart(
|
|
vendor_id: int,
|
|
session_id: str,
|
|
current_customer: Optional[Customer] = Depends(get_current_customer_optional),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get cart (session or customer)"""
|
|
service = CartService()
|
|
cart = await service.get_or_create_cart(
|
|
vendor_id,
|
|
db,
|
|
customer_id=current_customer.id if current_customer else None,
|
|
session_id=session_id if not current_customer else None
|
|
)
|
|
return cart
|
|
|
|
@router.post("/{session_id}/items", response_model=CartItemResponse)
|
|
async def add_to_cart(
|
|
vendor_id: int,
|
|
session_id: str,
|
|
item_data: CartItemAdd,
|
|
current_customer: Optional[Customer] = Depends(get_current_customer_optional),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Add item to cart"""
|
|
service = CartService()
|
|
cart = await service.get_or_create_cart(vendor_id, db, current_customer.id if current_customer else None, session_id)
|
|
item = await service.add_to_cart(cart, item_data.product_id, item_data.quantity, db)
|
|
return item
|
|
```
|
|
|
|
## 🎨 Frontend Implementation
|
|
|
|
### Templates
|
|
|
|
#### Shop Homepage (`templates/shop/home.html`)
|
|
|
|
```html
|
|
{% extends "shop/base_shop.html" %}
|
|
|
|
{% block content %}
|
|
<div x-data="shopHome()" x-init="loadFeaturedProducts()">
|
|
<!-- Hero Section -->
|
|
<div class="hero-section">
|
|
<h1>Welcome to {{ vendor.name }}</h1>
|
|
<p>{{ vendor.description }}</p>
|
|
<a href="/shop/products" class="btn btn-primary btn-lg">
|
|
Shop Now
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Featured Products -->
|
|
<div class="products-section">
|
|
<h2>Featured Products</h2>
|
|
<div class="product-grid">
|
|
<template x-for="product in featuredProducts" :key="product.id">
|
|
<div class="product-card">
|
|
<a :href="`/shop/products/${product.id}`">
|
|
<img :src="product.featured_image || '/static/images/no-image.png'"
|
|
:alt="product.title">
|
|
<h3 x-text="product.title"></h3>
|
|
<p class="price">€<span x-text="product.price.toFixed(2)"></span></p>
|
|
</a>
|
|
<button @click="addToCart(product.id)" class="btn btn-primary btn-sm">
|
|
Add to Cart
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
```
|
|
|
|
#### Product Detail (`templates/shop/product.html`)
|
|
|
|
```html
|
|
{% extends "shop/base_shop.html" %}
|
|
|
|
{% block content %}
|
|
<div x-data="productDetail()" x-init="loadProduct()">
|
|
<div class="product-detail">
|
|
<!-- Product Images -->
|
|
<div class="product-images">
|
|
<img :src="product.featured_image" :alt="product.title" class="main-image">
|
|
</div>
|
|
|
|
<!-- Product Info -->
|
|
<div class="product-info">
|
|
<h1 x-text="product.title"></h1>
|
|
<div class="price-section">
|
|
<span class="price">€<span x-text="product.price"></span></span>
|
|
<template x-if="product.compare_at_price">
|
|
<span class="compare-price">€<span x-text="product.compare_at_price"></span></span>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="product-description" x-html="product.description"></div>
|
|
|
|
<!-- Quantity Selector -->
|
|
<div class="quantity-selector">
|
|
<label>Quantity</label>
|
|
<input
|
|
type="number"
|
|
x-model.number="quantity"
|
|
:min="1"
|
|
:max="product.stock_quantity"
|
|
>
|
|
<span class="stock-info" x-text="`${product.stock_quantity} in stock`"></span>
|
|
</div>
|
|
|
|
<!-- Add to Cart -->
|
|
<button
|
|
@click="addToCart()"
|
|
class="btn btn-primary btn-lg"
|
|
:disabled="!canAddToCart || adding"
|
|
>
|
|
<span x-show="!adding">Add to Cart</span>
|
|
<span x-show="adding" class="loading-spinner"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
window.productId = {{ product.id }};
|
|
window.vendorId = {{ vendor.id }};
|
|
</script>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
function productDetail() {
|
|
return {
|
|
product: {},
|
|
quantity: 1,
|
|
adding: false,
|
|
loading: false,
|
|
|
|
get canAddToCart() {
|
|
return this.product.stock_quantity >= this.quantity && this.quantity > 0;
|
|
},
|
|
|
|
async loadProduct() {
|
|
this.loading = true;
|
|
try {
|
|
this.product = await apiClient.get(
|
|
`/api/v1/public/vendors/${window.vendorId}/products/${window.productId}`
|
|
);
|
|
} catch (error) {
|
|
showNotification('Failed to load product', 'error');
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async addToCart() {
|
|
this.adding = true;
|
|
try {
|
|
const sessionId = getOrCreateSessionId();
|
|
await apiClient.post(
|
|
`/api/v1/public/vendors/${window.vendorId}/cart/${sessionId}/items`,
|
|
{
|
|
product_id: this.product.id,
|
|
quantity: this.quantity
|
|
}
|
|
);
|
|
|
|
showNotification('Added to cart!', 'success');
|
|
updateCartCount(); // Update cart icon
|
|
} catch (error) {
|
|
showNotification(error.message || 'Failed to add to cart', 'error');
|
|
} finally {
|
|
this.adding = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|
|
```
|
|
|
|
#### Shopping Cart (`templates/shop/cart.html`)
|
|
|
|
Full Alpine.js reactive cart with real-time totals and quantity updates.
|
|
|
|
## ✅ Testing Checklist
|
|
|
|
### Backend Tests
|
|
- [ ] Customer registration works
|
|
- [ ] Duplicate email prevention works
|
|
- [ ] Customer login/authentication works
|
|
- [ ] Customer number generation is unique
|
|
- [ ] Public product browsing works without auth
|
|
- [ ] Product search/filtering works
|
|
- [ ] Cart creation works (session and customer)
|
|
- [ ] Add to cart works
|
|
- [ ] Update cart quantity works
|
|
- [ ] Remove from cart works
|
|
- [ ] Cart persists across sessions
|
|
- [ ] Cart merges after login
|
|
- [ ] Vendor isolation maintained
|
|
|
|
### Frontend Tests
|
|
- [ ] Shop homepage loads
|
|
- [ ] Product listing displays
|
|
- [ ] Product search works
|
|
- [ ] Product detail page works
|
|
- [ ] Customer registration form works
|
|
- [ ] Customer login works
|
|
- [ ] Add to cart works
|
|
- [ ] Cart updates in real-time (Alpine.js)
|
|
- [ ] Cart icon shows count
|
|
- [ ] Mobile responsive
|
|
|
|
## ➡️ Next Steps
|
|
|
|
After completing Slice 4, move to **Slice 5: Order Processing** to complete the checkout flow and order management.
|
|
|
|
---
|
|
|
|
**Slice 4 Status**: 📋 Not Started
|
|
**Dependencies**: Slices 1, 2, & 3 must be complete
|
|
**Estimated Duration**: 5 days |