Rewrite on the Bennett platform: Spring Boot + Vite/React SPA

Replaces the Next.js app. Same site, same look; the parts that were decisions rather
than markup now live in Java.

- catalogue, curated order, category filter and image URLs move from a TypeScript array
  into Postgres behind /api/products and /api/categories
- contact form uses the shared platform-starter-contact: validate, RECORD, send, then
  fan out to n8n. Recording first means a relay outage costs a notification, not an enquiry
- PageMetaController rewrites title/description/OG per route, replacing what Next's SSR
  gave crawlers and link-preview scrapers
- 50MB of photos leave the repo for the MinIO bucket, re-encoded to webp (14MB) with EXIF
  (including phone GPS) stripped
- fixes a catalogue typo: 'Strawberry Pie' was category 'Pies', which no filter matched, so
  it was unreachable unless browsing All
This commit is contained in:
2026-07-22 22:09:51 -05:00
parent 207415dbbe
commit f471462d05
164 changed files with 4223 additions and 14003 deletions
@@ -0,0 +1,63 @@
import { useState } from 'react';
interface ProductGalleryProps {
images: string[];
alt: string;
}
/**
* The square photo on a product card, with arrows when there's more than one shot.
*
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to
* React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a
* build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop,
* arrows only when they'd do something.
*/
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
const [index, setIndex] = useState(0);
const many = images.length > 1;
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
return (
<div className="relative aspect-square bg-bakery-100">
{images.map((src, i) => (
<img
key={src}
src={src}
// Only the visible frame gets described; the rest are decorative duplicates of the same item.
alt={i === 0 ? alt : ''}
loading="lazy"
decoding="async"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
i === index ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
aria-hidden={i === index ? undefined : true}
/>
))}
{many && (
<>
<button
type="button"
onClick={() => step(-1)}
aria-label={`Previous photo of ${alt}`}
className="absolute left-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<span aria-hidden="true">{'<'}</span>
</button>
<button
type="button"
onClick={() => step(1)}
aria-label={`Next photo of ${alt}`}
className="absolute right-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<span aria-hidden="true">{'>'}</span>
</button>
</>
)}
</div>
);
};
export default ProductGallery;