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>
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user