compiling project documentation
This commit is contained in:
887
docs/__temp/__PROJECT_ROADMAP/slice4_doc.md
Normal file
887
docs/__temp/__PROJECT_ROADMAP/slice4_doc.md
Normal file
@@ -0,0 +1,887 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user