From 286eb2f06c609a350f0c25c1d22a8b34cabee7f8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 1 Feb 2026 00:02:14 +0000 Subject: [PATCH] feat: add product detail page with add-to-cart functionality - Create dynamic product page at /products/[slug] - Add variant selection buttons - Add quantity controls - Implement cart management with localStorage cart_id - Update cart page with product images, quantity controls, and remove functionality Co-Authored-By: Claude Opus 4.5 --- frontend/app/cart/page.tsx | 261 +++++++++++++++----- frontend/app/products/[slug]/page.tsx | 329 ++++++++++++++++++++++++++ 2 files changed, 533 insertions(+), 57 deletions(-) create mode 100644 frontend/app/products/[slug]/page.tsx diff --git a/frontend/app/cart/page.tsx b/frontend/app/cart/page.tsx index d4b4098..b751a69 100644 --- a/frontend/app/cart/page.tsx +++ b/frontend/app/cart/page.tsx @@ -1,12 +1,16 @@ "use client"; import { useState, useEffect } from "react"; +import Link from "next/link"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; interface CartItem { id: string; product_slug: string; product_name: string; - variant: string | null; + variant_sku: string; + variant_name: string | null; quantity: number; unit_price: number; subtotal: number; @@ -23,45 +27,99 @@ export default function CartPage() { const [cart, setCart] = useState(null); const [loading, setLoading] = useState(true); const [checkingOut, setCheckingOut] = useState(false); + const [updating, setUpdating] = useState(null); - useEffect(() => { + const fetchCart = async () => { const cartId = localStorage.getItem("cart_id"); if (cartId) { - fetch( - `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/cart/${cartId}` - ) - .then((res) => (res.ok ? res.json() : null)) - .then(setCart) - .finally(() => setLoading(false)); - } else { - setLoading(false); + try { + const res = await fetch(`${API_URL}/cart/${cartId}`); + if (res.ok) { + const data = await res.json(); + setCart(data); + } else { + // Cart expired or deleted + localStorage.removeItem("cart_id"); + setCart(null); + } + } catch { + setCart(null); + } } + setLoading(false); + }; + + useEffect(() => { + fetchCart(); }, []); + const updateQuantity = async (itemId: string, newQuantity: number) => { + if (!cart || newQuantity < 1) return; + + setUpdating(itemId); + try { + const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quantity: newQuantity }), + }); + + if (res.ok) { + const updatedCart = await res.json(); + setCart(updatedCart); + } + } catch { + console.error("Failed to update quantity"); + } finally { + setUpdating(null); + } + }; + + const removeItem = async (itemId: string) => { + if (!cart) return; + + setUpdating(itemId); + try { + const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, { + method: "DELETE", + }); + + if (res.ok) { + const updatedCart = await res.json(); + setCart(updatedCart); + } + } catch { + console.error("Failed to remove item"); + } finally { + setUpdating(null); + } + }; + const handleCheckout = async () => { if (!cart) return; setCheckingOut(true); try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/checkout/session`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - cart_id: cart.id, - success_url: `${window.location.origin}/checkout/success`, - cancel_url: `${window.location.origin}/cart`, - }), - } - ); + const res = await fetch(`${API_URL}/checkout/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + cart_id: cart.id, + success_url: `${window.location.origin}/checkout/success`, + cancel_url: `${window.location.origin}/cart`, + }), + }); if (res.ok) { const { checkout_url } = await res.json(); window.location.href = checkout_url; + } else { + const data = await res.json(); + alert(data.detail || "Failed to start checkout"); } } catch (error) { console.error("Checkout error:", error); + alert("Failed to start checkout"); } finally { setCheckingOut(false); } @@ -69,8 +127,10 @@ export default function CartPage() { if (loading) { return ( -
-

Loading cart...

+
+
+
+
); } @@ -80,9 +140,12 @@ export default function CartPage() {

Your Cart

Your cart is empty.

- + Continue Shopping - +
); } @@ -91,23 +154,71 @@ export default function CartPage() {

Your Cart

-
-
+
+
{cart.items.map((item) => (
-
-
-

{item.product_name}

- {item.variant && ( + {/* Product Image */} + +
+ {item.product_name} +
+ + + {/* Product Info */} +
+ + {item.product_name} + + {item.variant_name && (

- Variant: {item.variant} + {item.variant_name}

)} -

Qty: {item.quantity}

+

+ ${item.unit_price.toFixed(2)} each +

+ + {/* Quantity Controls */} +
+ + {item.quantity} + + +
+ + {/* Subtotal */}

${item.subtotal.toFixed(2)}

@@ -115,31 +226,67 @@ export default function CartPage() { ))}
-
-

Order Summary

-
-
- Subtotal - ${cart.subtotal.toFixed(2)} + {/* Order Summary */} +
+
+

Order Summary

+
+
+ + Subtotal ({cart.item_count} item{cart.item_count !== 1 ? "s" : ""}) + + ${cart.subtotal.toFixed(2)} +
+
+ Shipping + Calculated at checkout +
-
- Shipping - Calculated at checkout +
+
+ Total + ${cart.subtotal.toFixed(2)} +
+ + + Continue Shopping +
-
-
- Total - ${cart.subtotal.toFixed(2)} -
-
-
diff --git a/frontend/app/products/[slug]/page.tsx b/frontend/app/products/[slug]/page.tsx new file mode 100644 index 0000000..4def623 --- /dev/null +++ b/frontend/app/products/[slug]/page.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; + +interface ProductVariant { + name: string; + sku: string; + provider: string; + price: number; +} + +interface Product { + slug: string; + name: string; + description: string; + category: string; + product_type: string; + tags: string[]; + image_url: string; + base_price: number; + variants: ProductVariant[]; + is_active: boolean; +} + +export default function ProductPage() { + const params = useParams(); + const router = useRouter(); + const slug = params.slug as string; + + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedVariant, setSelectedVariant] = useState(null); + const [quantity, setQuantity] = useState(1); + const [addingToCart, setAddingToCart] = useState(false); + const [addedToCart, setAddedToCart] = useState(false); + + useEffect(() => { + async function fetchProduct() { + try { + const res = await fetch(`${API_URL}/products/${slug}`); + if (!res.ok) { + if (res.status === 404) { + setError("Product not found"); + } else { + setError("Failed to load product"); + } + return; + } + const data = await res.json(); + setProduct(data); + if (data.variants && data.variants.length > 0) { + setSelectedVariant(data.variants[0]); + } + } catch { + setError("Failed to load product"); + } finally { + setLoading(false); + } + } + + if (slug) { + fetchProduct(); + } + }, [slug]); + + const getOrCreateCart = async (): Promise => { + // Check for existing cart in localStorage + let cartId = localStorage.getItem("cart_id"); + + if (cartId) { + // Verify cart still exists + try { + const res = await fetch(`${API_URL}/cart/${cartId}`); + if (res.ok) { + return cartId; + } + } catch { + // Cart doesn't exist, create new one + } + } + + // Create new cart + try { + const res = await fetch(`${API_URL}/cart`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (res.ok) { + const data = await res.json(); + cartId = data.id; + localStorage.setItem("cart_id", cartId!); + return cartId; + } + } catch { + return null; + } + + return null; + }; + + const handleAddToCart = async () => { + if (!product || !selectedVariant) return; + + setAddingToCart(true); + try { + const cartId = await getOrCreateCart(); + if (!cartId) { + alert("Failed to create cart"); + return; + } + + const res = await fetch(`${API_URL}/cart/${cartId}/items`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + product_slug: product.slug, + variant_sku: selectedVariant.sku, + quantity: quantity, + }), + }); + + if (res.ok) { + setAddedToCart(true); + setTimeout(() => setAddedToCart(false), 3000); + } else { + const data = await res.json(); + alert(data.detail || "Failed to add to cart"); + } + } catch { + alert("Failed to add to cart"); + } finally { + setAddingToCart(false); + } + }; + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (error || !product) { + return ( +
+
+

{error || "Product not found"}

+ + Back to Products + +
+
+ ); + } + + return ( +
+ {/* Breadcrumb */} + + +
+ {/* Product Image */} +
+ {product.name} +
+ + {/* Product Details */} +
+
+ + {product.category} / {product.product_type} + +
+ +

{product.name}

+ +

{product.description}

+ +
+ ${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)} +
+ + {/* Variant Selection */} + {product.variants && product.variants.length > 1 && ( +
+ +
+ {product.variants.map((variant) => ( + + ))} +
+
+ )} + + {/* Quantity */} +
+ +
+ + {quantity} + +
+
+ + {/* Add to Cart Button */} + + + {/* View Cart Link */} + {addedToCart && ( + + View Cart + + )} + + {/* Tags */} + {product.tags && product.tags.length > 0 && ( +
+ Tags: +
+ {product.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} +
+
+
+ ); +}