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
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { fetchCategories, fetchProducts, type Product } from '@/lib/api';
import ProductGallery from '@/components/ProductGallery';
const ProductsPage = () => {
const [categories, setCategories] = useState<string[]>(['All']);
const [selectedCategory, setSelectedCategory] = useState('All');
const [products, setProducts] = useState<Product[]>([]);
const [failed, setFailed] = useState(false);
useEffect(() => {
fetchCategories().then(setCategories).catch(() => setFailed(true));
}, []);
// The filter is applied by the API, not in the browser — one source of truth for what's in a
// category. `ignore` drops a slow response that lost the race to a newer click.
useEffect(() => {
let ignore = false;
setFailed(false);
fetchProducts(selectedCategory)
.then((p) => { if (!ignore) setProducts(p); })
.catch(() => { if (!ignore) setFailed(true); });
return () => { ignore = true; };
}, [selectedCategory]);
return (
<div className="min-h-screen bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Our products
</h1>
</header>
{/* Products Section */}
<div className="container mx-auto px-4 pb-16">
{/* Categories */}
<div className="flex flex-wrap justify-center gap-4 mb-12">
{categories.map((category) => (
<motion.button
key={category}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setSelectedCategory(category)}
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
selectedCategory === category
? 'bg-bakery-600 text-white border-bakery-600'
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100'
}`}
>
{category}
</motion.button>
))}
</div>
{failed && (
<p role="alert" className="text-center text-bakery-800">
We could not load the menu just now. Please refresh, or call us on{' '}
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
</p>
)}
{/* Products Grid */}
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
<AnimatePresence>
{products.map((product) => (
<motion.div
key={product.id}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="group bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition duration-300"
>
{/* Image Container */}
<div className="relative w-full overflow-hidden">
<ProductGallery images={product.images} alt={product.name} />
</div>
{/* Content */}
<div className="p-6 text-center">
<h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide">
{product.name}
</h3>
<span className="text-xs uppercase tracking-[0.15em] text-bakery-600">
{product.category}
</span>
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
</div>
</div>
);
};
export default ProductsPage;