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