# 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 %}
{% endblock %} ``` #### Product Detail (`templates/shop/product.html`) ```html {% extends "shop/base_shop.html" %} {% block content %}