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

This commit is contained in:
notshop 2026-04-26 16:36:12 +00:00
parent 869e6c77b7
commit 8493a00aa4

150
client/src/pages/shop.tsx Normal file
View file

@ -0,0 +1,150 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "wouter";
import { Search, SlidersHorizontal } from "lucide-react";
import { useCart } from "../hooks/use-cart";
import { toast } from "../hooks/use-toast";
interface Product {
id: number;
handle: string;
title: string;
description?: string;
price: string;
compareAtPrice?: string;
images?: string[];
status: string;
tags?: string[];
hsaFsa?: boolean;
}
function ProductCard({ product }: { product: Product }) {
const { addItem } = useCart();
const image = product.images?.[0];
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault();
try {
await addItem(product.id);
toast({ title: "Added to cart", description: product.title });
} catch {
toast({ title: "Error", description: "Could not add to cart", variant: "destructive" });
}
};
return (
<Link href={`/products/${product.handle}`} data-testid={`product-card-${product.id}`} className="group block">
<div className="rounded-xl border border-border overflow-hidden bg-card hover:shadow-md transition-shadow">
<div className="aspect-square bg-muted relative overflow-hidden">
{image ? (
<img src={image} alt={product.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground/30 text-4xl">📦</div>
)}
{product.hsaFsa && (
<span className="absolute top-2 left-2 bg-green-100 text-green-800 text-xs px-2 py-0.5 rounded-full font-medium">HSA/FSA</span>
)}
</div>
<div className="p-4">
<h3 className="font-medium text-sm leading-snug mb-1 line-clamp-2" data-testid={`product-title-${product.id}`}>{product.title}</h3>
<div className="flex items-center gap-2 mt-2">
<span className="font-semibold" data-testid={`product-price-${product.id}`}>${parseFloat(product.price).toFixed(2)}</span>
{product.compareAtPrice && parseFloat(product.compareAtPrice) > parseFloat(product.price) && (
<span className="text-sm text-muted-foreground line-through">${parseFloat(product.compareAtPrice).toFixed(2)}</span>
)}
</div>
<button
onClick={handleAddToCart}
className="mt-3 w-full bg-primary text-primary-foreground py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
data-testid={`button-add-to-cart-${product.id}`}
>
Add to cart
</button>
</div>
</div>
</Link>
);
}
export default function ShopPage() {
const [q, setQ] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery<{ products: Product[]; total: number; pageSize: number }>({
queryKey: ["/api/products", { q: search, page }],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "24" });
if (search) params.set("q", search);
const res = await fetch(`/api/products?${params}`);
return res.json();
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(q);
setPage(1);
};
return (
<div className="max-w-6xl mx-auto px-4 py-10">
<div className="mb-8 flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Shop</h1>
<form onSubmit={handleSearch} className="flex gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search products..."
className="w-full pl-9 pr-4 py-2 border border-border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/30"
data-testid="input-search"
/>
</div>
<button type="submit" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium" data-testid="button-search">
Search
</button>
</form>
</div>
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card animate-pulse">
<div className="aspect-square bg-muted rounded-t-xl" />
<div className="p-4 space-y-2">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-1/3" />
</div>
</div>
))}
</div>
) : data?.products.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<p className="text-lg">No products found</p>
{search && <button onClick={() => { setSearch(""); setQ(""); }} className="mt-2 text-sm text-primary hover:underline">Clear search</button>}
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{data?.products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
{data && data.total > data.pageSize && (
<div className="flex justify-center gap-3 mt-10">
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}
className="px-4 py-2 border border-border rounded-md text-sm disabled:opacity-40 hover:bg-muted transition-colors">
Previous
</button>
<span className="px-4 py-2 text-sm text-muted-foreground">Page {page}</span>
<button onClick={() => setPage((p) => p + 1)} disabled={page * data.pageSize >= data.total}
className="px-4 py-2 border border-border rounded-md text-sm disabled:opacity-40 hover:bg-muted transition-colors">
Next
</button>
</div>
)}
</>
)}
</div>
);
}