Files
pak-jabit/storefront/src/components/ProductGrid.tsx
T
juxtapoandClaude Opus 5 a58a61b98c Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling
A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.

What is here:
  storefront/  the Next.js app
  backend/     ONLY the custom Spree code. Spree itself is a prebuilt image
               that mounts no app code, so every file here needs an explicit
               volume entry in docker-compose.yml or it is silently ignored.
  catalogue/   the scripts that loaded and maintain the products
  docs/        manifest, mock registry, napkins

Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.

The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:09:55 +08:00

384 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo, useEffect } from "react";
import Image from "next/image";
import { DEFAULT_CURRENCY, symbolFor, type CurrencyCode } from "@/lib/currency";
interface Product {
id: string;
name: string;
slug: string;
description: string;
thumbnail_url: string | null;
price: { amount: string; currency: string; display_amount: string };
}
function extractBrand(description: string, name: string): string {
if (description && description.includes(" - ")) {
return description.split(" - ")[0].trim();
}
const known = [
"G-Shock", "H&M", "Lacoste", "Tommy Hilfiger", "Cotton On", "GAP",
"Giordano", "Superdry", "Under Armour", "Calvin Klein", "Camel Active",
"Electro Denim Lab", "Trendyol", "Swiss Polo", "Adidas", "Nike",
"New Balance", "Levi's", "Mango Man", "Urban Revivo", "Puma",
"Jack & Jones", "Harley-Davidson", "Clarks", "Ben Sherman",
"Connor", "Jordan", "Desigual", "Dr. Cardin", "Veja", "Hunter Boots",
];
for (const b of known) {
if (name.toLowerCase().includes(b.toLowerCase())) return b;
}
return "Other";
}
type SortOption = "default" | "price-asc" | "price-desc" | "name";
// 24, not 25: the grid is 2 columns on mobile and 3 on desktop, so 24 is the
// largest round page size that fills every row in both layouts.
const PER_PAGE = 24;
export default function ProductGrid({
products,
currency = DEFAULT_CURRENCY,
}: {
products: Product[];
currency?: CurrencyCode;
}) {
const [selectedBrands, setSelectedBrands] = useState<Set<string>>(new Set());
const [sort, setSort] = useState<SortOption>("default");
const [showFilters, setShowFilters] = useState(false);
const [page, setPage] = useState(1);
const symbol = symbolFor(currency);
const productsWithBrand = useMemo(
() => products.map((p) => ({ ...p, brand: extractBrand(p.description, p.name) })),
[products]
);
const brands = useMemo(() => {
const counts = new Map<string, number>();
for (const p of productsWithBrand) {
counts.set(p.brand, (counts.get(p.brand) || 0) + 1);
}
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }));
}, [productsWithBrand]);
const maxPrice = useMemo(() => {
const amounts = productsWithBrand.map((p) => parseFloat(p.price.amount));
if (!amounts.length) return 0;
return Math.ceil(Math.max(...amounts) / 100) * 100;
}, [productsWithBrand]);
// Roughly 100 notches across whatever range this currency spans. A fixed
// step of 50 was unusable in MYR (max ~800) and too coarse in ZAR.
const step = useMemo(() => Math.max(1, Math.round(maxPrice / 100)), [maxPrice]);
const [priceRange, setPriceRange] = useState<[number, number]>([0, maxPrice]);
// Prices change scale entirely when the currency changes (R3,185 vs RM799 vs
// $196), so a range carried over from the previous currency would silently
// hide most of the catalogue. Reset it whenever the ceiling moves.
useEffect(() => {
setPriceRange([0, maxPrice]);
}, [maxPrice, currency]);
const filtered = useMemo(() => {
let result = productsWithBrand;
if (selectedBrands.size > 0) {
result = result.filter((p) => selectedBrands.has(p.brand));
}
const price = parseFloat;
result = result.filter(
(p) => price(p.price.amount) >= priceRange[0] && price(p.price.amount) <= priceRange[1]
);
switch (sort) {
case "price-asc":
result = [...result].sort((a, b) => price(a.price.amount) - price(b.price.amount));
break;
case "price-desc":
result = [...result].sort((a, b) => price(b.price.amount) - price(a.price.amount));
break;
case "name":
result = [...result].sort((a, b) => a.name.localeCompare(b.name));
break;
}
return result;
}, [productsWithBrand, selectedBrands, sort, priceRange]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE));
// A filter that shrinks the result set can strand the viewer on a page that no
// longer exists (page 6 of 2), which renders an empty grid with no explanation.
// Snap back to the first page whenever the selection changes.
useEffect(() => {
setPage(1);
}, [selectedBrands, sort, priceRange, currency]);
const paged = useMemo(
() => filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE),
[filtered, page]
);
const firstShown = filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1;
const lastShown = Math.min(page * PER_PAGE, filtered.length);
function goToPage(next: number) {
setPage(Math.min(Math.max(1, next), totalPages));
if (typeof window !== "undefined") {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}
// Up to 7 page buttons; longer runs collapse the middle to an ellipsis so the
// row never wraps on a phone.
const pageNumbers = useMemo<(number | "gap")[]>(() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const out: (number | "gap")[] = [1];
const from = Math.max(2, page - 1);
const to = Math.min(totalPages - 1, page + 1);
if (from > 2) out.push("gap");
for (let i = from; i <= to; i++) out.push(i);
if (to < totalPages - 1) out.push("gap");
out.push(totalPages);
return out;
}, [page, totalPages]);
function toggleBrand(brand: string) {
setSelectedBrands((prev) => {
const next = new Set(prev);
if (next.has(brand)) next.delete(brand);
else next.add(brand);
return next;
});
}
function clearFilters() {
setSelectedBrands(new Set());
setPriceRange([0, maxPrice]);
setSort("default");
}
// Keep the chosen currency when following a product link.
function productHref(slug: string) {
return currency === DEFAULT_CURRENCY
? `/product/${slug}`
: `/product/${slug}?currency=${currency}`;
}
const activeFilterCount = selectedBrands.size + (priceRange[0] > 0 || priceRange[1] < maxPrice ? 1 : 0);
return (
<section className="max-w-7xl mx-auto px-4 py-12">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-foreground">All Products</h2>
<span className="text-sm text-foreground/50">
{filtered.length === 0
? "0 items"
: `${firstShown}–${lastShown} of ${filtered.length} items`}
</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowFilters(!showFilters)}
className="md:hidden flex items-center gap-2 px-3 py-2 rounded-lg border border-foreground/20 text-sm text-foreground hover:border-ochre transition-colors"
>
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="bg-ochre text-[#0E0D0B] text-xs font-bold w-5 h-5 rounded-full flex items-center justify-center">
{activeFilterCount}
</span>
)}
</button>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="bg-card-bg text-foreground text-sm border border-foreground/20 rounded-lg px-3 py-2 focus:outline-none focus:border-ochre"
>
<option value="default">Sort by</option>
<option value="price-asc">Price: Low → High</option>
<option value="price-desc">Price: High → Low</option>
<option value="name">Name: A → Z</option>
</select>
</div>
</div>
<div className="flex gap-8">
{/* Sidebar Filters */}
<aside
className={`${
showFilters ? "block" : "hidden"
} md:block w-full md:w-56 shrink-0`}
>
<div className="sticky top-4 space-y-6">
{/* Clear filters */}
{activeFilterCount > 0 && (
<button
onClick={clearFilters}
className="text-sm text-ochre hover:text-ochre-dark transition-colors"
>
Clear all filters ✕
</button>
)}
{/* Brand Filter */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">Brand</h3>
<div className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
{brands.map(({ name, count }) => (
<label
key={name}
className="flex items-center gap-2 cursor-pointer group"
>
<input
type="checkbox"
checked={selectedBrands.has(name)}
onChange={() => toggleBrand(name)}
className="w-4 h-4 rounded border-foreground/30 accent-[#C8873A]"
/>
<span className="text-sm text-foreground/80 group-hover:text-foreground transition-colors flex-1">
{name}
</span>
<span className="text-xs text-foreground/40">{count}</span>
</label>
))}
</div>
</div>
{/* Price Filter */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Price <span className="text-xs font-normal text-foreground/40">({currency})</span>
</h3>
<div className="space-y-2">
<input
type="range"
min={0}
max={maxPrice}
step={step}
value={priceRange[1]}
onChange={(e) => setPriceRange([priceRange[0], parseInt(e.target.value)])}
className="w-full accent-[#C8873A]"
/>
<div className="flex justify-between text-xs text-foreground/50">
<span>{symbol}{priceRange[0].toLocaleString()}</span>
<span>{symbol}{priceRange[1].toLocaleString()}</span>
</div>
</div>
</div>
</div>
</aside>
{/* Product Grid */}
<div className="flex-1">
{filtered.length === 0 ? (
<div className="text-center py-16 text-foreground/50">
<p className="text-lg">No products match your filters</p>
<button
onClick={clearFilters}
className="mt-3 text-ochre hover:underline text-sm"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{paged.map((product) => (
<a
key={product.id}
href={productHref(product.slug)}
className="group bg-card-bg rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-[3/4] relative overflow-hidden bg-[#EDE6DA]">
{product.thumbnail_url ? (
<Image
src={product.thumbnail_url}
alt={product.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-foreground/20 text-4xl">
📷
</div>
)}
</div>
<div className="p-3">
<p className="text-xs text-ochre font-medium">{product.brand}</p>
<h3 className="text-sm font-medium text-card-text mt-1 line-clamp-2 leading-tight">
{product.name}
</h3>
<p className="text-sm font-bold text-card-text mt-2">
{product.price.display_amount}
</p>
</div>
</a>
))}
</div>
)}
{totalPages > 1 && (
<nav
aria-label="Pagination"
className="flex items-center justify-center gap-1.5 mt-10 flex-wrap"
>
<button
onClick={() => goToPage(page - 1)}
disabled={page === 1}
aria-label="Previous page"
className="px-3 py-2 rounded-lg border border-foreground/20 text-sm text-foreground hover:border-ochre transition-colors disabled:opacity-30 disabled:hover:border-foreground/20 disabled:cursor-not-allowed"
>
←
</button>
{pageNumbers.map((n, i) =>
n === "gap" ? (
<span
key={`gap-${i}`}
className="px-2 text-sm text-foreground/30 select-none"
>
…
</span>
) : (
<button
key={n}
onClick={() => goToPage(n)}
aria-current={n === page ? "page" : undefined}
className={`min-w-[2.25rem] px-3 py-2 rounded-lg border text-sm transition-colors ${
n === page
? "bg-ochre border-ochre text-[#0E0D0B] font-semibold"
: "border-foreground/20 text-foreground hover:border-ochre"
}`}
>
{n}
</button>
)
)}
<button
onClick={() => goToPage(page + 1)}
disabled={page === totalPages}
aria-label="Next page"
className="px-3 py-2 rounded-lg border border-foreground/20 text-sm text-foreground hover:border-ochre transition-colors disabled:opacity-30 disabled:hover:border-foreground/20 disabled:cursor-not-allowed"
>
→
</button>
</nav>
)}
</div>
</div>
</section>
);
}