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>
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|