chore: add client/src/pages/product.tsx

This commit is contained in:
notshop 2026-04-26 16:36:11 +00:00
parent edea3d97c7
commit 869e6c77b7

View file

@ -0,0 +1,190 @@
import { useState } from "react";
import { useParams, Link } from "wouter";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ShoppingBag, Shield, Package } from "lucide-react";
import { useCart } from "../hooks/use-cart";
import { toast } from "../hooks/use-toast";
interface Variant {
id: number;
title: string;
price: string;
compareAtPrice?: string;
inventoryQty: number;
inventoryPolicy: string;
imageUrl?: string;
}
interface Product {
id: number;
handle: string;
title: string;
description?: string;
descriptionHtml?: string;
price: string;
compareAtPrice?: string;
images?: string[];
tags?: string[];
hsaFsa?: boolean;
ingredients?: string;
vendor?: string;
}
export default function ProductPage() {
const { handle } = useParams<{ handle: string }>();
const { addItem } = useCart();
const [selectedVariantId, setSelectedVariantId] = useState<number | undefined>();
const [selectedImage, setSelectedImage] = useState(0);
const [adding, setAdding] = useState(false);
const { data, isLoading, error } = useQuery<{ product: Product; variants: Variant[] }>({
queryKey: ["/api/products", handle],
queryFn: async () => {
const res = await fetch(`/api/products/${handle}`);
if (!res.ok) throw new Error("Product not found");
return res.json();
},
});
if (isLoading) return (
<div className="max-w-6xl mx-auto px-4 py-16 animate-pulse">
<div className="grid md:grid-cols-2 gap-10">
<div className="aspect-square bg-muted rounded-xl" />
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-3/4" />
<div className="h-6 bg-muted rounded w-1/4" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-5/6" />
</div>
</div>
</div>
);
if (error || !data) return (
<div className="max-w-xl mx-auto px-4 py-20 text-center">
<h1 className="text-2xl font-bold mb-4">Product not found</h1>
<Link href="/" className="text-primary hover:underline"> Back to shop</Link>
</div>
);
const { product, variants } = data;
const images = product.images || [];
const selectedVariant = variants.find((v) => v.id === selectedVariantId);
const price = selectedVariant?.price || product.price;
const compareAtPrice = selectedVariant?.compareAtPrice || product.compareAtPrice;
const isOnSale = compareAtPrice && parseFloat(compareAtPrice) > parseFloat(price);
const displayImage = selectedVariant?.imageUrl || images[selectedImage];
const inStock = !selectedVariant || selectedVariant.inventoryPolicy === "continue" || selectedVariant.inventoryQty > 0;
const handleAdd = async () => {
setAdding(true);
try {
await addItem(product.id, selectedVariantId);
toast({ title: "Added to cart", description: product.title });
} catch {
toast({ title: "Error", description: "Could not add to cart", variant: "destructive" });
} finally {
setAdding(false);
}
};
return (
<div className="max-w-6xl mx-auto px-4 py-10" data-testid="product-page">
<Link href="/" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors">
<ChevronLeft size={14} /> Back to shop
</Link>
<div className="grid md:grid-cols-2 gap-10 lg:gap-16">
<div className="space-y-3">
<div className="aspect-square rounded-xl border border-border overflow-hidden bg-muted" data-testid="product-main-image">
{displayImage ? (
<img src={displayImage} alt={product.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-6xl text-muted-foreground/20">📦</div>
)}
</div>
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((img, i) => (
<button key={i} onClick={() => setSelectedImage(i)}
className={`flex-shrink-0 w-16 h-16 rounded-md border-2 overflow-hidden transition-colors ${selectedImage === i ? "border-primary" : "border-transparent"}`}
data-testid={`product-thumbnail-${i}`}>
<img src={img} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
<div>
{product.vendor && <p className="text-sm text-muted-foreground mb-1">{product.vendor}</p>}
<h1 className="text-3xl font-bold tracking-tight mb-3" data-testid="product-name">{product.title}</h1>
<div className="flex items-center gap-3 mb-6">
<span className="text-2xl font-bold" data-testid="product-price">${parseFloat(price).toFixed(2)}</span>
{isOnSale && <span className="text-lg text-muted-foreground line-through">${parseFloat(compareAtPrice!).toFixed(2)}</span>}
{product.hsaFsa && (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
<Shield size={11} /> HSA/FSA Eligible
</span>
)}
</div>
{variants.length > 1 && (
<div className="mb-6">
<p className="text-sm font-medium mb-2">Option</p>
<div className="flex flex-wrap gap-2">
{variants.map((v) => (
<button key={v.id}
onClick={() => setSelectedVariantId(v.id)}
className={`px-4 py-2 border rounded-md text-sm transition-colors ${selectedVariantId === v.id ? "border-primary bg-primary/5 font-medium" : "border-border hover:border-primary/50"}`}
data-testid={`variant-${v.id}`}>
{v.title}
{v.inventoryQty <= 0 && v.inventoryPolicy !== "continue" && <span className="ml-1 text-muted-foreground">(out of stock)</span>}
</button>
))}
</div>
</div>
)}
{variants.length > 1 && !selectedVariantId && (
<p className="text-sm text-muted-foreground mb-4">Please select an option</p>
)}
<button
onClick={handleAdd}
disabled={adding || (variants.length > 1 && !selectedVariantId) || !inStock}
className="w-full bg-primary text-primary-foreground py-3 rounded-md font-semibold flex items-center justify-center gap-2 hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed mb-4"
data-testid="button-add-to-cart"
>
<ShoppingBag size={18} />
{adding ? "Adding..." : !inStock ? "Out of stock" : "Add to cart"}
</button>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
<Package size={14} />
<span>Free shipping on orders over $75</span>
</div>
{(product.description || product.descriptionHtml) && (
<div className="prose prose-sm max-w-none text-muted-foreground">
{product.descriptionHtml ? (
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
) : (
<p>{product.description}</p>
)}
</div>
)}
{product.ingredients && (
<div className="mt-6 p-4 bg-muted/40 rounded-lg border border-border">
<h3 className="font-medium text-sm mb-2">Ingredients</h3>
<p className="text-sm text-muted-foreground">{product.ingredients}</p>
</div>
)}
</div>
</div>
</div>
);
}