diff --git a/app/backend/dummy_data.py b/app/backend/dummy_data.py index a2a9989312cf487af4a89289dc8691639877ac47..a6035eea3da3aa7fceaa1de298542de1caaddaa6 100644 --- a/app/backend/dummy_data.py +++ b/app/backend/dummy_data.py @@ -8,6 +8,8 @@ from backend.models.models import ( Order, OrderItem, Payment, + Cart, + CartItem, ) from backend.utils.hashing import hash_password @@ -196,3 +198,17 @@ def insert_dummy_data(session: Session): ] session.add_all(order_items) session.commit() + + if not session.query(Cart).first(): + # Create example cart for the first user + cart = Cart(user_id=1) + session.add(cart) + session.commit() + + # Add a couple of items to the cart + cart_items = [ + CartItem(cart_id=cart.id, product_id=3, quantity=2, price=102.99), + CartItem(cart_id=cart.id, product_id=4, quantity=1, price=103.99) + ] + session.add_all(cart_items) + session.commit() diff --git a/app/backend/main.py b/app/backend/main.py index 0bbd87693267aa32eafce6b8493306e2ae23f864..700b51bb5c69d9691f84d42a38ff93b6e87e7e1a 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -5,7 +5,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from backend.routes import auth, shop, product, category, search, order, payment +from backend.routes import auth, shop, product, category, search, order, payment, cart from backend.database import init_db from core.config import settings @@ -30,6 +30,8 @@ app.include_router(category.router, prefix="/category", tags=["category"]) app.include_router(order.router, prefix="/order", tags=["order"]) # Also include order router at /orders for backward compatibility app.include_router(order.router, prefix="/orders", tags=["orders"]) +# Use dedicated cart router +app.include_router(cart.router, prefix="/cart", tags=["cart"]) @app.get("/") diff --git a/app/backend/models/models.py b/app/backend/models/models.py index 366abc384d49f7576393a18537bda4c6103b698d..4c1f1d9f19095248d7cfba27a10d1cefe68a7607 100644 --- a/app/backend/models/models.py +++ b/app/backend/models/models.py @@ -15,6 +15,7 @@ class User(SQLModel, table=True): shops: List["Shop"] = Relationship(back_populates="owner") orders: List["Order"] = Relationship(back_populates="user") + carts: List["Cart"] = Relationship(back_populates="user") class Shop(SQLModel, table=True): @@ -102,3 +103,25 @@ class Payment(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.utcnow) user: User = Relationship(back_populates="payments") + + +class Cart(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + user: Optional["User"] = Relationship(back_populates="carts") + cart_items: List["CartItem"] = Relationship(back_populates="cart") + + +class CartItem(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + cart_id: int = Field(foreign_key="cart.id") + product_id: int = Field(foreign_key="product.id") + quantity: int + price: float + + # Relationships + cart: Optional["Cart"] = Relationship(back_populates="cart_items") + product: Optional["Product"] = Relationship() diff --git a/app/backend/routes/cart.py b/app/backend/routes/cart.py new file mode 100644 index 0000000000000000000000000000000000000000..b481a2d2fea5a2d0a534dec09a4ff2c49ae6b946 --- /dev/null +++ b/app/backend/routes/cart.py @@ -0,0 +1,342 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, delete +from geopy.geocoders import Nominatim +from geopy.distance import geodesic +from backend.database import get_session +from backend.routes.auth import get_current_user +from backend.models.models import Order, OrderItem, User, Product, Shop, Payment, Cart, CartItem +from backend.schemas.order import OrderCreate, OrderRead, OrderUpdate, CartItemCreate, CartItemRead, CartRead + +router = APIRouter() + + +@router.post("/add") +def add_to_cart( + cart_item: CartItemCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Add a product to the user's cart""" + # Validate product exists and has enough stock + product = session.get(Product, cart_item.product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + if product.stock < cart_item.quantity: + raise HTTPException(status_code=400, detail="Not enough stock available") + + # Verify the shop exists + shop = session.get(Shop, cart_item.shop_id) + if not shop: + raise HTTPException(status_code=404, detail="Shop not found") + + # Get or create a cart for the user + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + cart = Cart(user_id=current_user.id) + session.add(cart) + session.commit() + session.refresh(cart) + + # Check if the item already exists in the cart + existing_item = session.exec( + select(CartItem).where( + CartItem.cart_id == cart.id, + CartItem.product_id == cart_item.product_id + ) + ).first() + + if existing_item: + # Update quantity if item already exists + existing_item.quantity += cart_item.quantity + session.add(existing_item) + else: + # Create new cart item + new_cart_item = CartItem( + cart_id=cart.id, + product_id=cart_item.product_id, + quantity=cart_item.quantity, + price=product.price + ) + session.add(new_cart_item) + + session.commit() + + return {"message": "Item added to cart successfully"} + + +@router.get("/items") +def get_cart_items( + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Get all items in the user's cart with product details""" + # Get user's cart + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + return {"items": []} + + # Get cart items with product details + cart_items = session.exec( + select(CartItem).where(CartItem.cart_id == cart.id) + ).all() + + items_with_details = [] + for item in cart_items: + product = session.get(Product, item.product_id) + if product: + product_dict = { + "id": item.id, # Use cart item ID for operations + "product_id": product.id, + "name": product.name, + "price": product.price, + "shop_id": product.shop_id, + "shop_name": product.shop.name if product.shop else "Unknown Shop", + "images": product.images, + "quantity": item.quantity, + "subtotal": item.quantity * product.price + } + items_with_details.append(product_dict) + + total_price = sum(item.get("subtotal", 0) for item in items_with_details) + + return { + "items": items_with_details, + "total_price": total_price, + "item_count": len(items_with_details) + } + + +@router.post("/checkout") +def checkout_cart( + checkout_data: dict, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Process checkout for selected cart items""" + # Extract parameters from request body + delivery_address = checkout_data.get("delivery_address") + payment_id = checkout_data.get("payment_id") + selected_items = checkout_data.get("selected_items", []) + + if not delivery_address or not payment_id: + raise HTTPException( + status_code=400, + detail="Missing required parameters: delivery_address and payment_id" + ) + + if not selected_items: + raise HTTPException( + status_code=400, + detail="No items selected for checkout" + ) + + # Validate payment method + payment = session.get(Payment, payment_id) + if not payment or payment.user_id != current_user.id: + raise HTTPException( + status_code=400, + detail="Invalid or unauthorized payment method" + ) + + # Get user's cart + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + # Get selected cart items + cart_items = [] + for item_id in selected_items: + item = session.exec( + select(CartItem).where( + CartItem.id == item_id, + CartItem.cart_id == cart.id + ) + ).first() + + if item: + cart_items.append(item) + + if not cart_items: + raise HTTPException(status_code=400, detail="No valid items selected") + + # Group cart items by shop + shop_items = {} + for item in cart_items: + product = session.get(Product, item.product_id) + if not product or product.stock < item.quantity: + raise HTTPException( + status_code=400, detail=f"Product {item.product_id} is out of stock" + ) + + if product.shop_id not in shop_items: + shop_items[product.shop_id] = [] + + shop_items[product.shop_id].append(item) + + # Create orders for each shop + orders = [] + for shop_id, items in shop_items.items(): + shop = session.get(Shop, shop_id) + + # Geocode the delivery address + geolocator = Nominatim(user_agent="order_locator") + try: + delivery_location = geolocator.geocode(delivery_address) + if not delivery_location: + # Fall back to London coordinates if geocoding fails + delivery_location = type('obj', (object,), { + 'latitude': 51.5074, + 'longitude': -0.1278 + }) + except Exception as e: + print(f"Geocoding error: {e}") + # Fall back to London coordinates + delivery_location = type('obj', (object,), { + 'latitude': 51.5074, + 'longitude': -0.1278 + }) + + # Calculate the distance between the shop and the delivery location + shop_location = (shop.latitude, shop.longitude) + delivery_coordinates = (delivery_location.latitude, delivery_location.longitude) + distance_km = geodesic(shop_location, delivery_coordinates).kilometers + + # Calculate the shipping price ($1 per km) + shipping_price = distance_km * 1.0 + + # Calculate the total price + product_total = sum(item.price * item.quantity for item in items) + total_price = shipping_price + product_total + + # Create the order + new_order = Order( + user_id=current_user.id, + shop_id=shop_id, + payment_id=payment_id, + total_price=total_price, + shipping_price=shipping_price, + status="pending", + delivery_address=delivery_address, + delivery_latitude=delivery_location.latitude, + delivery_longitude=delivery_location.longitude, + ) + session.add(new_order) + session.commit() + session.refresh(new_order) + + # Create order items and update product stock + for item in items: + order_item = OrderItem( + order_id=new_order.id, + product_id=item.product_id, + quantity=item.quantity, + price=item.price, + ) + session.add(order_item) + + # Update product stock + product = session.get(Product, item.product_id) + product.stock -= item.quantity + session.add(product) + + # Remove checked out item from cart + session.delete(item) + + orders.append(new_order.id) + + session.commit() + + return {"message": "Orders created successfully", "order_ids": orders} + + +@router.delete("/remove/{item_id}") +def remove_from_cart( + item_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Remove an item from the cart""" + # Get user's cart + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + # Find the cart item + cart_item = session.exec( + select(CartItem).where( + CartItem.id == item_id, + CartItem.cart_id == cart.id + ) + ).first() + + if not cart_item: + raise HTTPException(status_code=404, detail="Item not found in cart") + + # Remove the item + session.delete(cart_item) + session.commit() + + return {"message": "Item removed from cart successfully"} + + +@router.put("/update/{item_id}") +def update_cart_item( + item_id: int, + update_data: dict, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Update the quantity of a cart item""" + # Get user's cart + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + # Find the cart item + cart_item = session.exec( + select(CartItem).where( + CartItem.id == item_id, + CartItem.cart_id == cart.id + ) + ).first() + + if not cart_item: + raise HTTPException(status_code=404, detail="Item not found in cart") + + # Update quantity + new_quantity = update_data.get("quantity") + if new_quantity is not None: + if new_quantity <= 0: + # If quantity is 0 or negative, remove the item + session.delete(cart_item) + else: + # Check if product has enough stock + product = session.get(Product, cart_item.product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + if product.stock < new_quantity: + raise HTTPException(status_code=400, detail="Not enough stock available") + + cart_item.quantity = new_quantity + session.add(cart_item) + + session.commit() + + return {"message": "Cart item updated successfully"} \ No newline at end of file diff --git a/app/backend/routes/order.py b/app/backend/routes/order.py index ee9c0756970cea463e418edb13ff52bca98231cd..46f456f14137b715eb495fbfa96b7f1f53db5014 100644 --- a/app/backend/routes/order.py +++ b/app/backend/routes/order.py @@ -4,12 +4,271 @@ from geopy.geocoders import Nominatim from geopy.distance import geodesic from backend.database import get_session from backend.routes.auth import get_current_user -from backend.models.models import Order, OrderItem, User, Product, Shop, Payment -from backend.schemas.order import OrderCreate, OrderRead, OrderUpdate +from backend.models.models import Order, OrderItem, User, Product, Shop, Payment, Cart, CartItem +from backend.schemas.order import OrderCreate, OrderRead, OrderUpdate, CartItemCreate router = APIRouter() +@router.post("/cart/add") +def add_to_cart( + cart_item: CartItemCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + # Validate product exists and has enough stock + product = session.get(Product, cart_item.product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + if product.stock < cart_item.quantity: + raise HTTPException(status_code=400, detail="Not enough stock available") + + # Verify the shop exists + shop = session.get(Shop, cart_item.shop_id) + if not shop: + raise HTTPException(status_code=404, detail="Shop not found") + + # Get or create a cart for the user + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + cart = Cart(user_id=current_user.id) + session.add(cart) + session.commit() + session.refresh(cart) + + # Check if the item already exists in the cart + existing_item = session.exec( + select(CartItem).where( + CartItem.cart_id == cart.id, + CartItem.product_id == cart_item.product_id + ) + ).first() + + if existing_item: + # Update quantity if item already exists + existing_item.quantity += cart_item.quantity + session.add(existing_item) + else: + # Create new cart item + new_cart_item = CartItem( + cart_id=cart.id, + product_id=cart_item.product_id, + quantity=cart_item.quantity, + price=product.price + ) + session.add(new_cart_item) + + session.commit() + + return {"message": "Item added to cart successfully"} + + +@router.get("/cart/view-items") +def get_cart_items( + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + # Get user's cart + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + return {"items": []} + + # Get cart items with product details + cart_items = session.exec( + select(CartItem).where(CartItem.cart_id == cart.id) + ).all() + + items_with_details = [] + for item in cart_items: + product = session.get(Product, item.product_id) + if product: + product_dict = { + "id": product.id, + "name": product.name, + "price": product.price, + "shop_id": product.shop_id, + "images": product.images, + "quantity": item.quantity + } + items_with_details.append(product_dict) + + return {"items": items_with_details} + + +@router.post("/cart/checkout") +def checkout_cart( + checkout_data: dict, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + # Extract parameters from request body + delivery_address = checkout_data.get("delivery_address") + payment_id = checkout_data.get("payment_id") + + if not delivery_address or not payment_id: + raise HTTPException(status_code=400, detail="Missing required parameters: delivery_address and payment_id") + + # Get user's cart + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + # Get cart items + cart_items = session.exec( + select(CartItem).where(CartItem.cart_id == cart.id) + ).all() + + if not cart_items: + raise HTTPException(status_code=400, detail="Cart is empty") + + # Validate payment method + payment = session.get(Payment, payment_id) + if not payment or payment.user_id != current_user.id: + raise HTTPException( + status_code=400, detail="Invalid or unauthorized payment method" + ) + + # Group cart items by shop + shop_items = {} + for item in cart_items: + product = session.get(Product, item.product_id) + if not product or product.stock < item.quantity: + raise HTTPException( + status_code=400, detail=f"Product {item.product_id} is out of stock" + ) + + if product.shop_id not in shop_items: + shop_items[product.shop_id] = [] + + shop_items[product.shop_id].append(item) + + # Create orders for each shop + orders = [] + for shop_id, items in shop_items.items(): + shop = session.get(Shop, shop_id) + + # Geocode the delivery address + geolocator = Nominatim(user_agent="order_locator") + try: + delivery_location = geolocator.geocode(delivery_address) + if not delivery_location: + # Fall back to London coordinates if geocoding fails + delivery_location = type('obj', (object,), { + 'latitude': 51.5074, + 'longitude': -0.1278 + }) + except Exception as e: + print(f"Geocoding error: {e}") + # Fall back to London coordinates + delivery_location = type('obj', (object,), { + 'latitude': 51.5074, + 'longitude': -0.1278 + }) + + # Calculate the distance between the shop and the delivery location + shop_location = (shop.latitude, shop.longitude) + delivery_coordinates = (delivery_location.latitude, delivery_location.longitude) + distance_km = geodesic(shop_location, delivery_coordinates).kilometers + + # Calculate the shipping price ($1 per km) + shipping_price = distance_km * 1.0 + + # Calculate the total price + product_total = sum(item.price * item.quantity for item in items) + total_price = shipping_price + product_total + + # Create the order + new_order = Order( + user_id=current_user.id, + shop_id=shop_id, + payment_id=payment_id, + total_price=total_price, + shipping_price=shipping_price, + status="pending", + delivery_address=delivery_address, + delivery_latitude=delivery_location.latitude, + delivery_longitude=delivery_location.longitude, + ) + session.add(new_order) + session.commit() + session.refresh(new_order) + + # Create order items and update product stock + for item in items: + order_item = OrderItem( + order_id=new_order.id, + product_id=item.product_id, + quantity=item.quantity, + price=item.price, + ) + session.add(order_item) + + # Update product stock + product = session.get(Product, item.product_id) + product.stock -= item.quantity + session.add(product) + + orders.append(new_order.id) + + # Clear the cart after checkout + session.exec(delete(CartItem).where(CartItem.cart_id == cart.id)) + session.commit() + + return {"message": "Orders created successfully", "order_ids": orders} + + +@router.delete("/cart/remove/{product_id}") +def remove_from_cart( + product_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + # Get user's cart + cart = session.exec( + select(Cart).where(Cart.user_id == current_user.id) + ).first() + + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + # Find the cart item + cart_item = session.exec( + select(CartItem).where( + CartItem.cart_id == cart.id, + CartItem.product_id == product_id + ) + ).first() + + if not cart_item: + raise HTTPException(status_code=404, detail="Item not found in cart") + + # Remove the item + session.delete(cart_item) + session.commit() + + return {"message": "Item removed from cart successfully"} + + +@router.post("/add") +def legacy_add_to_cart( + cart_item: CartItemCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + # For backward compatibility, redirect to new cart endpoint + return add_to_cart(cart_item, session, current_user) + + @router.post("/", response_model=OrderRead) def create_order( order_data: OrderCreate, diff --git a/app/backend/schemas/order.py b/app/backend/schemas/order.py index 8cb9ede5854630152c8cf7d13f7ea086a33713ce..416f3327b0bd94df1c35a11ab851074f3644dc3f 100644 --- a/app/backend/schemas/order.py +++ b/app/backend/schemas/order.py @@ -8,6 +8,33 @@ class OrderItemCreate(BaseModel): quantity: int +class CartItemCreate(BaseModel): + shop_id: int + product_id: int + quantity: int + price: Optional[float] = None + + +class CartItemRead(BaseModel): + id: int + product_id: int + quantity: int + price: float + + class Config: + from_attributes = True + + +class CartRead(BaseModel): + id: int + user_id: int + created_at: datetime + cart_items: List[CartItemRead] + + class Config: + from_attributes = True + + class OrderCreate(BaseModel): shop_id: int payment_id: int diff --git a/app/frontend/components/product/view_product.py b/app/frontend/components/product/view_product.py index d6d3dab66f117fcbee16d0703f70157781efc5c5..f7af0b4a3a9514bc9432c2ef0cd0f37a2a02f4bd 100644 --- a/app/frontend/components/product/view_product.py +++ b/app/frontend/components/product/view_product.py @@ -481,8 +481,13 @@ def view_product_frame( if response.status_code == 200: messagebox.showinfo("Success", "Product added to cart successfully!") - # Optionally switch to cart view - switch_func("user_orders") + # Ask if user wants to view cart + view_cart = messagebox.askyesno( + "View Cart", + "Would you like to view your cart?" + ) + if view_cart: + switch_func("user_orders") else: error_msg = response.json().get("detail", "Unknown error occurred") messagebox.showerror("Error", f"Failed to add to cart: {error_msg}") diff --git a/app/frontend/components/user_orders.py b/app/frontend/components/user_orders.py index c8fadbea54447326a911dde1a09607dd4126c77d..6ce2816c6f4c03bc6164180ac2149e2558c4df1b 100644 --- a/app/frontend/components/user_orders.py +++ b/app/frontend/components/user_orders.py @@ -116,8 +116,572 @@ def user_orders_frame(parent, switch_func, API_URL, token): content_frame = ctk.CTkFrame(main_section, fg_color=CARD_BG, corner_radius=15) content_frame.pack(side="left", fill="both", expand=True, padx=(0, 20), pady=20) - # ORDER HISTORY - orders_frame = ctk.CTkScrollableFrame(content_frame, fg_color="transparent") + # Tab control for Cart and Order History + tab_view = ctk.CTkTabview(content_frame, fg_color="transparent") + tab_view.pack(fill="both", expand=True, padx=10, pady=10) + + # Create tabs + cart_tab = tab_view.add("My Cart") + orders_tab = tab_view.add("Order History") + + # CART TAB + cart_frame = ctk.CTkScrollableFrame(cart_tab, fg_color="transparent") + cart_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Cart title with refresh button + title_frame = ctk.CTkFrame(cart_frame, fg_color="transparent") + title_frame.pack(fill="x", pady=(0, 20)) + + cart_title = ctk.CTkLabel( + title_frame, + text="Your Shopping Cart", + font=("Helvetica", 18, "bold"), + text_color=SHOPPING, + ) + cart_title.pack(side="left", anchor="w") + + def refresh_cart(): + """Function to reload cart items""" + load_cart_items() + + refresh_cart_btn = ctk.CTkButton( + title_frame, + text="🔄 Refresh", + font=("Helvetica", 12), + fg_color="#4A4A4A", + hover_color="#5A5A5A", + width=100, + height=28, + command=refresh_cart + ) + refresh_cart_btn.pack(side="right", padx=5) + + cart_items_frame = ctk.CTkFrame(cart_frame, fg_color="transparent") + cart_items_frame.pack(fill="both", expand=True) + + # Checkout frame (initially empty) + checkout_frame = ctk.CTkFrame(cart_frame, fg_color="transparent") + checkout_frame.pack(fill="x", pady=10) + + def load_cart_items(): + # Clear existing items + for widget in cart_items_frame.winfo_children(): + widget.destroy() + + # Clear checkout frame + for widget in checkout_frame.winfo_children(): + widget.destroy() + + try: + headers = {"Authorization": f"Bearer {frame.token}"} + response = requests.get(f"{API_URL}/cart/items", headers=headers) + + if response.status_code == 200: + cart_data = response.json() + cart_items = cart_data.get("items", []) + total_price = cart_data.get("total_price", 0) + + if not cart_items: + empty_label = ctk.CTkLabel( + cart_items_frame, + text="Your cart is empty", + font=("Helvetica", 14), + ) + empty_label.pack(pady=20) + return + + # Header row + header_frame = ctk.CTkFrame(cart_items_frame, fg_color="#333333") + header_frame.pack(fill="x", pady=(0, 10)) + + # Configure the same grid layout as the items + header_frame.grid_columnconfigure(0, weight=0, minsize=40) # Checkbox column + header_frame.grid_columnconfigure(1, weight=1, minsize=300) # Product column (expandable) + header_frame.grid_columnconfigure(2, weight=0, minsize=100) # Unit price column + header_frame.grid_columnconfigure(3, weight=0, minsize=100) # Quantity column + header_frame.grid_columnconfigure(4, weight=0, minsize=100) # Subtotal column + header_frame.grid_columnconfigure(5, weight=0, minsize=90) # Actions column + + # Checkbox header (select all) + select_all_var = ctk.BooleanVar(value=False) + selected_items = {} # Dictionary to track selected items {item_id: bool} + + def toggle_select_all(): + for item_id, checkbox_var in selected_items.items(): + checkbox_var.set(select_all_var.get()) + + select_all_cb = ctk.CTkCheckBox( + header_frame, + text="", + variable=select_all_var, + command=toggle_select_all, + width=30 + ) + select_all_cb.grid(row=0, column=0, padx=5, pady=10, sticky="w") + + ctk.CTkLabel( + header_frame, + text="Product", + font=("Helvetica", 12, "bold"), + ).grid(row=0, column=1, padx=5, pady=10, sticky="w") + + ctk.CTkLabel( + header_frame, + text="Unit Price", + font=("Helvetica", 12, "bold"), + ).grid(row=0, column=2, padx=5, pady=10) + + ctk.CTkLabel( + header_frame, + text="Quantity", + font=("Helvetica", 12, "bold"), + ).grid(row=0, column=3, padx=5, pady=10) + + ctk.CTkLabel( + header_frame, + text="Subtotal", + font=("Helvetica", 12, "bold"), + ).grid(row=0, column=4, padx=5, pady=10) + + ctk.CTkLabel( + header_frame, + text="Actions", + font=("Helvetica", 12, "bold"), + ).grid(row=0, column=5, padx=5, pady=10) + + # Create items list + for item in cart_items: + item_id = item.get("id") + # Create checkbox variable for this item + selected_items[item_id] = ctk.BooleanVar(value=False) + + item_frame = create_cart_item_frame(item, selected_items[item_id]) + item_frame.pack(fill="x", pady=5) + + # Add checkout section + divider = ctk.CTkFrame(checkout_frame, fg_color="#444444", height=1) + divider.pack(fill="x", pady=10) + + # Selected items summary + def update_checkout_summary(): + # Calculate selected items total + selected_total = 0 + selected_count = 0 + + for item in cart_items: + item_id = item.get("id") + if item_id in selected_items and selected_items[item_id].get(): + selected_total += item.get("subtotal", 0) + selected_count += 1 + + # Update summary label + summary_label.configure( + text=f"Selected Items: {selected_count} | Total: ₫{selected_total:,.0f}" + ) + + # Update checkout button state + if selected_count > 0: + checkout_button.configure(state="normal") + else: + checkout_button.configure(state="disabled") + + # Create a frame for the summary + bottom_frame = ctk.CTkFrame(checkout_frame, fg_color="transparent") + bottom_frame.pack(fill="x", pady=15) + + # Create the summary label + summary_label = ctk.CTkLabel( + bottom_frame, + text="Selected Items: 0 | Total: ₫0", + font=("Helvetica", 14, "bold"), + text_color=SHOPPING, + ) + summary_label.pack(side="left", padx=10) + + # Function to update selection count (bound to each checkbox) + def on_checkbox_change(): + update_checkout_summary() + + # Check if all are selected + all_selected = all(var.get() for var in selected_items.values()) + select_all_var.set(all_selected) + + # Bind the change function to all checkboxes + for var in selected_items.values(): + var.trace_add("write", lambda *args: on_checkbox_change()) + + # Delivery address + address_frame = ctk.CTkFrame(checkout_frame, fg_color="transparent") + address_frame.pack(fill="x", pady=10) + + address_label = ctk.CTkLabel( + address_frame, + text="Delivery Address:", + font=("Helvetica", 14), + text_color="#AAAAAA", + ) + address_label.pack(side="left", padx=10) + + address_entry = ctk.CTkEntry(address_frame, width=300) + address_entry.pack(side="left", padx=10) + address_entry.insert(0, "10 Downing Street, London, UK") # Default address + + # Payment selection + payment_frame = ctk.CTkFrame(checkout_frame, fg_color="transparent") + payment_frame.pack(fill="x", pady=10) + + payment_label = ctk.CTkLabel( + payment_frame, + text="Payment Method:", + font=("Helvetica", 14), + text_color="#AAAAAA", + ) + payment_label.pack(side="left", padx=10) + + # Function to load payment methods + def load_payment_methods(): + payment_options.clear() + payment_ids.clear() + + # Get payment methods + try: + payments_response = requests.get(f"{API_URL}/payment/user", headers=headers) + + if payments_response.status_code == 200: + payments = payments_response.json() + for payment in payments: + payment_text = f"{payment.get('payment_method')} ending in {payment.get('card_number')[-4:]}" + payment_options.append(payment_text) + payment_ids[payment_text] = payment.get('id') + + if not payment_options: + payment_options.append("No payment methods available") + + # Update dropdown + payment_dropdown.configure(values=payment_options) + payment_var.set(payment_options[0] if payment_options else "") + except Exception as e: + print(f"Error loading payment methods: {e}") + + # Get payment methods + payment_options = [] + payment_ids = {} + + # Create a frame to hold the dropdown and refresh button + payment_dropdown_frame = ctk.CTkFrame(payment_frame, fg_color="transparent") + payment_dropdown_frame.pack(side="left", fill="x", expand=True) + + payment_var = ctk.StringVar(value="") + payment_dropdown = ctk.CTkOptionMenu( + payment_dropdown_frame, + values=["Loading..."], + variable=payment_var, + width=250, + ) + payment_dropdown.pack(side="left", padx=10) + + # Refresh button + refresh_btn = ctk.CTkButton( + payment_dropdown_frame, + text="🔄", + width=40, + height=28, + command=load_payment_methods, + fg_color="#4A4A4A", + hover_color="#5A5A5A", + ) + refresh_btn.pack(side="left", padx=5) + + # Load payment methods initially + load_payment_methods() + + def add_payment_method(): + """Function to redirect to payment methods page""" + switch_func("user_payments") + + add_payment_btn = ctk.CTkButton( + payment_frame, + text="Add Payment Method", + font=("Helvetica", 12), + fg_color="#5A5A5A", + hover_color="#6A6A6A", + command=add_payment_method, + width=150, + ) + add_payment_btn.pack(side="left", padx=10) + + # Checkout button + def handle_checkout(): + # Get selected item IDs + selected_item_ids = [ + item_id for item_id, var in selected_items.items() if var.get() + ] + + if not selected_item_ids: + messagebox.showerror("Error", "Please select at least one item") + return + + if not payment_options or payment_options[0] == "No payment methods available": + result = messagebox.askyesno( + "No Payment Method", + "You don't have any payment methods. Would you like to add one now?" + ) + if result: + switch_func("user_payments") + return + + delivery_addr = address_entry.get().strip() + if not delivery_addr: + messagebox.showerror("Error", "Please enter a delivery address") + return + + selected_payment = payment_var.get() + payment_id = payment_ids.get(selected_payment) + + if not payment_id: + messagebox.showerror("Error", "Invalid payment method selected") + return + + try: + checkout_response = requests.post( + f"{API_URL}/cart/checkout", + json={ + "delivery_address": delivery_addr, + "payment_id": payment_id, + "selected_items": selected_item_ids + }, + headers=headers + ) + + if checkout_response.status_code == 200: + messagebox.showinfo("Success", "Your order has been placed successfully!") + # Refresh order history and clear cart view + load_order_history() + load_cart_items() + # Switch to order history tab + tab_view.set("Order History") + else: + error_msg = checkout_response.json().get("detail", "Unknown error") + messagebox.showerror("Checkout Failed", f"Error: {error_msg}") + + except Exception as e: + messagebox.showerror("Error", f"Checkout failed: {str(e)}") + + checkout_button = ctk.CTkButton( + bottom_frame, + text="Checkout Selected Items", + font=("Helvetica", 14, "bold"), + fg_color="#ff4242", + hover_color="#ff6b6b", + command=handle_checkout, + state="disabled", # Initially disabled until items are selected + width=200, + height=40 + ) + checkout_button.pack(side="right", padx=15) + + else: + error_msg = response.json().get("detail", "Unknown error") + messagebox.showerror("Error", f"Failed to load cart: {error_msg}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to load cart: {str(e)}") + + def create_cart_item_frame(item, checkbox_var): + item_frame = ctk.CTkFrame(cart_items_frame, fg_color="#2b2b2b", corner_radius=5) + + # Create content frame - use grid layout for better alignment + content = ctk.CTkFrame(item_frame, fg_color="transparent") + content.pack(fill="x", padx=5, pady=10) + + # Configure grid columns with consistent widths + content.grid_columnconfigure(0, weight=0, minsize=40) # Checkbox column + content.grid_columnconfigure(1, weight=1, minsize=300) # Product column (expandable) + content.grid_columnconfigure(2, weight=0, minsize=100) # Unit price column + content.grid_columnconfigure(3, weight=0, minsize=100) # Quantity column + content.grid_columnconfigure(4, weight=0, minsize=100) # Subtotal column + content.grid_columnconfigure(5, weight=0, minsize=90) # Actions column + + # Checkbox - column 0 + item_checkbox = ctk.CTkCheckBox( + content, + text="", + variable=checkbox_var, + width=30 + ) + item_checkbox.grid(row=0, column=0, padx=5, sticky="w") + + # Product image and name - column 1 + product_frame = ctk.CTkFrame(content, fg_color="transparent") + product_frame.grid(row=0, column=1, padx=5, sticky="w") + + # Create horizontal layout for image and text + img_frame = ctk.CTkFrame(product_frame, fg_color="#333333", width=60, height=60) + img_frame.pack(side="left", padx=5, pady=5) + img_frame.pack_propagate(False) + + img_label = ctk.CTkLabel(img_frame, text="") + img_label.place(relx=0.5, rely=0.5, anchor="center") + + # Try to load product image + images = item.get("images", []) + if images: + img_url = images[0].get("image_url") + if img_url: + fixed_url = fix_url(img_url) + try: + resp = requests.get(fixed_url) + if resp.status_code == 200: + pil_img = Image.open(io.BytesIO(resp.content)).resize((50, 50)) + tk_img = ImageTk.PhotoImage(pil_img) + img_label.configure(image=tk_img, text="") + img_label.image = tk_img + except Exception as e: + print(f"Failed to load product image: {e}") + + # Product details + details_frame = ctk.CTkFrame(product_frame, fg_color="transparent") + details_frame.pack(side="left", fill="both", expand=True, padx=10) + + # Product name + product_name = ctk.CTkLabel( + details_frame, + text=item.get("name", "Unknown Product"), + font=("Helvetica", 12, "bold"), + justify="left", + anchor="w", + ) + product_name.pack(anchor="w") + + # Shop name + shop_name = ctk.CTkLabel( + details_frame, + text=f"Shop: {item.get('shop_name', 'Unknown Shop')}", + font=("Helvetica", 10), + text_color="#AAAAAA", + justify="left", + anchor="w", + ) + shop_name.pack(anchor="w") + + # Unit price - column 2 + unit_price = item.get("price", 0) + price_label = ctk.CTkLabel( + content, + text=f"₫{unit_price:,.0f}", + font=("Helvetica", 12), + ) + price_label.grid(row=0, column=2, padx=5) + + # Quantity adjustment - column 3 + quantity_frame = ctk.CTkFrame(content, fg_color="#1f1f1f") + quantity_frame.grid(row=0, column=3, padx=5) + + quantity = item.get("quantity", 1) + + def update_quantity(delta): + nonlocal quantity + item_id = item.get("id") + new_quantity = quantity + delta + + if 1 <= new_quantity <= 99: + try: + headers = {"Authorization": f"Bearer {frame.token}"} + response = requests.put( + f"{API_URL}/cart/update/{item_id}", + json={"quantity": new_quantity}, + headers=headers + ) + + if response.status_code == 200: + # Update display + quantity = new_quantity + quantity_label.configure(text=str(quantity)) + + # Update subtotal + subtotal = unit_price * quantity + subtotal_label.configure(text=f"₫{subtotal:,.0f}") + + # Reload cart to refresh totals + load_cart_items() + else: + error_msg = response.json().get("detail", "Unknown error") + messagebox.showerror("Error", f"Failed to update quantity: {error_msg}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to update quantity: {str(e)}") + + minus_btn = ctk.CTkButton( + quantity_frame, + text="-", + width=25, + height=25, + font=("Helvetica", 12), + command=lambda: update_quantity(-1) + ) + minus_btn.pack(side="left", padx=2, pady=2) + + quantity_label = ctk.CTkLabel( + quantity_frame, + text=str(quantity), + width=30, + font=("Helvetica", 12), + ) + quantity_label.pack(side="left") + + plus_btn = ctk.CTkButton( + quantity_frame, + text="+", + width=25, + height=25, + font=("Helvetica", 12), + command=lambda: update_quantity(1) + ) + plus_btn.pack(side="left", padx=2, pady=2) + + # Subtotal - column 4 + subtotal = item.get("subtotal", 0) + subtotal_label = ctk.CTkLabel( + content, + text=f"₫{subtotal:,.0f}", + font=("Helvetica", 12, "bold"), + text_color=SHOPPING, + ) + subtotal_label.grid(row=0, column=4, padx=5) + + # Actions - column 5 + def remove_item(): + try: + headers = {"Authorization": f"Bearer {frame.token}"} + response = requests.delete( + f"{API_URL}/cart/remove/{item.get('id')}", + headers=headers + ) + + if response.status_code == 200: + load_cart_items() # Refresh cart + else: + error_msg = response.json().get("detail", "Unknown error") + messagebox.showerror("Error", f"Failed to remove item: {error_msg}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to remove item: {str(e)}") + + remove_btn = ctk.CTkButton( + content, + text="Remove", + font=("Helvetica", 12), + fg_color="#ff4242", + hover_color="#ff6b6b", + width=70, + command=remove_item, + ) + remove_btn.grid(row=0, column=5, padx=5) + + return item_frame + + # ORDER HISTORY TAB + orders_frame = ctk.CTkScrollableFrame(orders_tab, fg_color="transparent") orders_frame.pack(fill="both", expand=True, padx=20, pady=20) # Header for orders @@ -226,6 +790,56 @@ def user_orders_frame(parent, switch_func, API_URL, token): font=("Helvetica", 12), text_color="white", ).pack(side="left") + + # Order items + items_frame = ctk.CTkFrame(details_frame, fg_color="transparent") + items_frame.pack(fill="x", pady=5) + + ctk.CTkLabel( + items_frame, + text="Ordered Items:", + font=("Helvetica", 12, "bold"), + text_color="#AAAAAA", + ).pack(anchor="w", padx=(10, 5), pady=(5, 0)) + + # Create a frame for each order item + order_items = order.get("order_items", []) + for item in order_items: + item_frame = ctk.CTkFrame(items_frame, fg_color="#1e1e1e", corner_radius=5) + item_frame.pack(fill="x", padx=10, pady=2) + + # Get product details + try: + headers = {"Authorization": f"Bearer {frame.token}"} + product_id = item.get("product_id") + response = requests.get(f"{API_URL}/product/get/{product_id}", headers=headers) + + if response.status_code == 200: + product = response.json() + product_name = product.get("name", "Unknown Product") + else: + product_name = f"Product #{product_id}" + except: + product_name = f"Product #{product_id}" + + # Product info + product_info = ctk.CTkLabel( + item_frame, + text=f"{product_name} × {item.get('quantity', 0)}", + font=("Helvetica", 12), + text_color="white", + ) + product_info.pack(side="left", padx=10, pady=5) + + # Item price + item_price = item.get("price", 0) * item.get("quantity", 0) + price_label = ctk.CTkLabel( + item_frame, + text=f"₫{item_price:,.0f}", + font=("Helvetica", 12), + text_color="#AAAAAA", + ) + price_label.pack(side="right", padx=10, pady=5) # Totals totals_frame = ctk.CTkFrame(details_frame, fg_color="transparent") @@ -271,7 +885,8 @@ def user_orders_frame(parent, switch_func, API_URL, token): return order_frame - # Load orders when the frame is created + # Load orders and cart when the frame is created load_order_history() + load_cart_items() return frame diff --git a/app/frontend/main.py b/app/frontend/main.py index 7d3d1339715ab96018b19452cd4b7f931800f49e..7a2c8a8ea06dee1987d75e47f954a84fd1b828b2 100644 --- a/app/frontend/main.py +++ b/app/frontend/main.py @@ -1,3 +1,9 @@ +import sys +import os + +# Add the current directory to the path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + import customtkinter as ctk from components.auth.login import login_frame from components.auth.register import register_frame @@ -10,6 +16,8 @@ from components.dashboard import dashboard_frame from components.user_details import user_details_frame from components.user_orders import user_orders_frame from components.user_payments import user_payments_frame +import traceback +import sys API_URL = "http://127.0.0.1:8000" access_token = None # Global token @@ -82,33 +90,50 @@ def initialize_authenticated_frames(token): frame.place(relx=0, rely=0, relwidth=1, relheight=1) -ctk.set_appearance_mode("dark") -ctk.set_default_color_theme("blue") +def main(): + global root, frames + try: + print("Starting Shopping App...") + ctk.set_appearance_mode("dark") + ctk.set_default_color_theme("blue") + + root = ctk.CTk() + root.title("Shopping App") + root.geometry("1000x800") + print("Window created successfully") -root = ctk.CTk() -root.title("Shopping App") -root.geometry("1000x800") + # Create frames + frames = {} -# Create frames -frames = {} + # Login and register don't need tokens + print("Creating login and register frames...") + login = login_frame(root, switch_frame, API_URL) + register = register_frame(root, switch_frame, API_URL) -# Login and register don't need tokens -login = login_frame(root, switch_frame, API_URL) -register = register_frame(root, switch_frame, API_URL) + # Collect all frames in a dictionary - start with unauthenticated frames + frames = { + "login": login, + "register": register, + } -# Collect all frames in a dictionary - start with unauthenticated frames -frames = { - "login": login, - "register": register, -} + # Place login and register frames + frames["login"].place(relx=0, rely=0, relwidth=1, relheight=1) + frames["register"].place(relx=0, rely=0, relwidth=1, relheight=1) + print("Login and register frames placed") + + # Create authenticated frames with empty token (they'll be recreated on login) + print("Initializing authenticated frames...") + initialize_authenticated_frames(access_token) + print("All frames initialized") -# Place login and register frames -frames["login"].place(relx=0, rely=0, relwidth=1, relheight=1) -frames["register"].place(relx=0, rely=0, relwidth=1, relheight=1) + # Start with login frame + switch_frame("login") + print("Starting main loop...") + root.mainloop() + except Exception as e: + print(f"Error starting application: {e}") + traceback.print_exc() -# Create authenticated frames with empty token (they'll be recreated on login) -initialize_authenticated_frames(access_token) -# Start with login frame -switch_frame("login") -root.mainloop() +if __name__ == "__main__": + main()