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:
parent
ca2d13d9a1
commit
286eb2f06c
|
|
@ -1,12 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 {
|
interface CartItem {
|
||||||
id: string;
|
id: string;
|
||||||
product_slug: string;
|
product_slug: string;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
variant: string | null;
|
variant_sku: string;
|
||||||
|
variant_name: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
|
|
@ -23,29 +27,80 @@ export default function CartPage() {
|
||||||
const [cart, setCart] = useState<Cart | null>(null);
|
const [cart, setCart] = useState<Cart | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [checkingOut, setCheckingOut] = useState(false);
|
const [checkingOut, setCheckingOut] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchCart = async () => {
|
||||||
const cartId = localStorage.getItem("cart_id");
|
const cartId = localStorage.getItem("cart_id");
|
||||||
if (cartId) {
|
if (cartId) {
|
||||||
fetch(
|
try {
|
||||||
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/cart/${cartId}`
|
const res = await fetch(`${API_URL}/cart/${cartId}`);
|
||||||
)
|
if (res.ok) {
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
const data = await res.json();
|
||||||
.then(setCart)
|
setCart(data);
|
||||||
.finally(() => setLoading(false));
|
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
// 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 () => {
|
const handleCheckout = async () => {
|
||||||
if (!cart) return;
|
if (!cart) return;
|
||||||
setCheckingOut(true);
|
setCheckingOut(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`${API_URL}/checkout/session`, {
|
||||||
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"}/checkout/session`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -53,15 +108,18 @@ export default function CartPage() {
|
||||||
success_url: `${window.location.origin}/checkout/success`,
|
success_url: `${window.location.origin}/checkout/success`,
|
||||||
cancel_url: `${window.location.origin}/cart`,
|
cancel_url: `${window.location.origin}/cart`,
|
||||||
}),
|
}),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { checkout_url } = await res.json();
|
const { checkout_url } = await res.json();
|
||||||
window.location.href = checkout_url;
|
window.location.href = checkout_url;
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.detail || "Failed to start checkout");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Checkout error:", error);
|
console.error("Checkout error:", error);
|
||||||
|
alert("Failed to start checkout");
|
||||||
} finally {
|
} finally {
|
||||||
setCheckingOut(false);
|
setCheckingOut(false);
|
||||||
}
|
}
|
||||||
|
|
@ -69,8 +127,10 @@ export default function CartPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-16 text-center">
|
<div className="container mx-auto px-4 py-16">
|
||||||
<p>Loading cart...</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -80,9 +140,12 @@ export default function CartPage() {
|
||||||
<div className="container mx-auto px-4 py-16 text-center">
|
<div className="container mx-auto px-4 py-16 text-center">
|
||||||
<h1 className="text-2xl font-bold mb-4">Your Cart</h1>
|
<h1 className="text-2xl font-bold mb-4">Your Cart</h1>
|
||||||
<p className="text-muted-foreground mb-8">Your cart is empty.</p>
|
<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
|
Continue Shopping
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -91,23 +154,71 @@ export default function CartPage() {
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-2xl font-bold mb-8">Your Cart</h1>
|
<h1 className="text-2xl font-bold mb-8">Your Cart</h1>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
<div className="md:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
{cart.items.map((item) => (
|
{cart.items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
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" />
|
{/* Product Image */}
|
||||||
<div className="flex-1">
|
<Link href={`/products/${item.product_slug}`}>
|
||||||
<h3 className="font-semibold">{item.product_name}</h3>
|
<div className="w-24 h-24 bg-muted rounded-lg overflow-hidden flex-shrink-0">
|
||||||
{item.variant && (
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Variant: {item.variant}
|
{item.variant_name}
|
||||||
</p>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtotal */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-bold">${item.subtotal.toFixed(2)}</p>
|
<p className="font-bold">${item.subtotal.toFixed(2)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,11 +226,15 @@ export default function CartPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-lg p-6 h-fit">
|
{/* Order Summary */}
|
||||||
<h2 className="font-bold mb-4">Order Summary</h2>
|
<div className="lg:col-span-1">
|
||||||
<div className="space-y-2 mb-4">
|
<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">
|
<div className="flex justify-between">
|
||||||
<span>Subtotal</span>
|
<span className="text-muted-foreground">
|
||||||
|
Subtotal ({cart.item_count} item{cart.item_count !== 1 ? "s" : ""})
|
||||||
|
</span>
|
||||||
<span>${cart.subtotal.toFixed(2)}</span>
|
<span>${cart.subtotal.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-muted-foreground">
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
|
@ -127,8 +242,8 @@ export default function CartPage() {
|
||||||
<span>Calculated at checkout</span>
|
<span>Calculated at checkout</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t pt-4 mb-4">
|
<div className="border-t pt-4 mb-6">
|
||||||
<div className="flex justify-between font-bold">
|
<div className="flex justify-between font-bold text-lg">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>${cart.subtotal.toFixed(2)}</span>
|
<span>${cart.subtotal.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,10 +251,42 @@ export default function CartPage() {
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckout}
|
onClick={handleCheckout}
|
||||||
disabled={checkingOut}
|
disabled={checkingOut}
|
||||||
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 disabled:opacity-50"
|
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{checkingOut ? "Redirecting..." : "Checkout"}
|
{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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue