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