chore: add client/src/pages/product.tsx
This commit is contained in:
parent
edea3d97c7
commit
869e6c77b7
1 changed files with 190 additions and 0 deletions
190
client/src/pages/product.tsx
Normal file
190
client/src/pages/product.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue