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:
2026-09-15 22:09:55 +08:00
co-authored by Claude Opus 5
commit a58a61b98c
98 changed files with 15524 additions and 0 deletions
+383
View File
@@ -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>
);
}