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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-01 00:02:14 +00:00
parent ca2d13d9a1
commit 286eb2f06c
2 changed files with 533 additions and 57 deletions

View File

@ -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<Cart | null>(null);
const [loading, setLoading] = useState(true);
const [checkingOut, setCheckingOut] = useState(false);
const [updating, setUpdating] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-16 text-center">
<p>Loading cart...</p>
<div className="container mx-auto px-4 py-16">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
</div>
);
}
@ -80,9 +140,12 @@ export default function CartPage() {
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Your Cart</h1>
<p className="text-muted-foreground mb-8">Your cart is empty.</p>
<a href="/products" className="text-primary hover:underline">
<Link
href="/products"
className="inline-flex items-center justify-center rounded-md bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Continue Shopping
</a>
</Link>
</div>
);
}
@ -91,23 +154,71 @@ export default function CartPage() {
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-8">Your Cart</h1>
<div className="grid md:grid-cols-3 gap-8">
<div className="md:col-span-2 space-y-4">
<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-4">
{cart.items.map((item) => (
<div
key={item.id}
className="flex items-center gap-4 p-4 border rounded-lg"
className={`flex items-center gap-4 p-4 border rounded-lg ${
updating === item.id ? "opacity-50" : ""
}`}
>
<div className="w-20 h-20 bg-muted rounded" />
<div className="flex-1">
<h3 className="font-semibold">{item.product_name}</h3>
{item.variant && (
{/* Product Image */}
<Link href={`/products/${item.product_slug}`}>
<div className="w-24 h-24 bg-muted rounded-lg overflow-hidden flex-shrink-0">
<img
src={`${API_URL}/designs/${item.product_slug}/image`}
alt={item.product_name}
className="w-full h-full object-cover"
/>
</div>
</Link>
{/* Product Info */}
<div className="flex-1 min-w-0">
<Link
href={`/products/${item.product_slug}`}
className="font-semibold hover:text-primary transition-colors"
>
{item.product_name}
</Link>
{item.variant_name && (
<p className="text-sm text-muted-foreground">
Variant: {item.variant}
{item.variant_name}
</p>
)}
<p className="text-sm">Qty: {item.quantity}</p>
<p className="text-sm text-muted-foreground">
${item.unit_price.toFixed(2)} each
</p>
{/* Quantity Controls */}
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
disabled={updating === item.id || item.quantity <= 1}
className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
>
-
</button>
<span className="w-8 text-center">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
disabled={updating === item.id}
className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
>
+
</button>
<button
onClick={() => removeItem(item.id)}
disabled={updating === item.id}
className="ml-4 text-sm text-red-600 hover:text-red-700 transition-colors disabled:opacity-50"
>
Remove
</button>
</div>
</div>
{/* Subtotal */}
<div className="text-right">
<p className="font-bold">${item.subtotal.toFixed(2)}</p>
</div>
@ -115,31 +226,67 @@ export default function CartPage() {
))}
</div>
<div className="border rounded-lg p-6 h-fit">
<h2 className="font-bold mb-4">Order Summary</h2>
<div className="space-y-2 mb-4">
<div className="flex justify-between">
<span>Subtotal</span>
<span>${cart.subtotal.toFixed(2)}</span>
{/* Order Summary */}
<div className="lg:col-span-1">
<div className="border rounded-lg p-6 sticky top-4">
<h2 className="font-bold text-lg mb-4">Order Summary</h2>
<div className="space-y-3 mb-4">
<div className="flex justify-between">
<span className="text-muted-foreground">
Subtotal ({cart.item_count} item{cart.item_count !== 1 ? "s" : ""})
</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Shipping</span>
<span>Calculated at checkout</span>
</div>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Shipping</span>
<span>Calculated at checkout</span>
<div className="border-t pt-4 mb-6">
<div className="flex justify-between font-bold text-lg">
<span>Total</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
</div>
<button
onClick={handleCheckout}
disabled={checkingOut}
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{checkingOut ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</span>
) : (
"Proceed to Checkout"
)}
</button>
<Link
href="/products"
className="block text-center mt-4 text-sm text-muted-foreground hover:text-primary transition-colors"
>
Continue Shopping
</Link>
</div>
<div className="border-t pt-4 mb-4">
<div className="flex justify-between font-bold">
<span>Total</span>
<span>${cart.subtotal.toFixed(2)}</span>
</div>
</div>
<button
onClick={handleCheckout}
disabled={checkingOut}
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 disabled:opacity-50"
>
{checkingOut ? "Redirecting..." : "Checkout"}
</button>
</div>
</div>
</div>

View File

@ -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<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(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<string | null> => {
// 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 (
<div className="container mx-auto px-4 py-16">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
</div>
);
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-16">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">{error || "Product not found"}</h1>
<Link
href="/products"
className="text-primary hover:underline"
>
Back to Products
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="mb-8 text-sm">
<Link href="/" className="text-muted-foreground hover:text-primary">
Home
</Link>
<span className="mx-2 text-muted-foreground">/</span>
<Link href="/products" className="text-muted-foreground hover:text-primary">
Products
</Link>
<span className="mx-2 text-muted-foreground">/</span>
<span className="text-foreground">{product.name}</span>
</nav>
<div className="grid md:grid-cols-2 gap-12">
{/* Product Image */}
<div className="aspect-square bg-muted rounded-lg overflow-hidden">
<img
src={`${API_URL}/designs/${product.slug}/image`}
alt={product.name}
className="w-full h-full object-cover"
/>
</div>
{/* Product Details */}
<div>
<div className="mb-2">
<span className="text-sm text-muted-foreground capitalize">
{product.category} / {product.product_type}
</span>
</div>
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<p className="text-muted-foreground mb-6">{product.description}</p>
<div className="text-3xl font-bold mb-6">
${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)}
</div>
{/* Variant Selection */}
{product.variants && product.variants.length > 1 && (
<div className="mb-6">
<label className="block text-sm font-medium mb-2">
Select Option
</label>
<div className="flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
key={variant.sku}
onClick={() => setSelectedVariant(variant)}
className={`px-4 py-2 rounded-md border transition-colors ${
selectedVariant?.sku === variant.sku
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 hover:border-primary"
}`}
>
{variant.name}
</button>
))}
</div>
</div>
)}
{/* Quantity */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">Quantity</label>
<div className="flex items-center gap-2">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
>
-
</button>
<span className="w-12 text-center font-medium">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-muted transition-colors"
>
+
</button>
</div>
</div>
{/* Add to Cart Button */}
<button
onClick={handleAddToCart}
disabled={addingToCart || !selectedVariant}
className={`w-full py-4 rounded-md font-medium transition-colors ${
addedToCart
? "bg-green-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{addingToCart ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Adding...
</span>
) : addedToCart ? (
<span className="flex items-center justify-center gap-2">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Added to Cart!
</span>
) : (
"Add to Cart"
)}
</button>
{/* View Cart Link */}
{addedToCart && (
<Link
href="/cart"
className="block text-center mt-4 text-primary hover:underline"
>
View Cart
</Link>
)}
{/* Tags */}
{product.tags && product.tags.length > 0 && (
<div className="mt-8 pt-6 border-t">
<span className="text-sm text-muted-foreground">Tags: </span>
<div className="flex flex-wrap gap-2 mt-2">
{product.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-xs bg-muted rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}