Files
orion/docs/project-roadmap/slice4_doc.md

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