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

26 KiB

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)

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)

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)

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)

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)

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)

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)

@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)

@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)

@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)

{% 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)

{% 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