The site renders itself: Thymeleaf pages, and a catering page among them

The public site was a React SPA. It is now server-rendered Thymeleaf, and the goodie box and catering
tables added in the previous commit have a page of their own. The look is unchanged: the templates
carry the same Tailwind classes the components did, and every one of the 241 classes the five pages
use resolves in the compiled stylesheet.

WHAT WENT AWAY. PageMetaController — 148 lines whose only job was to splice per-page <title> and OG
tags into one shell with regular expressions, with a test that read the real index.html so that
reformatting it failed the build instead of silently breaking the rewriting. A page rendered on the
server writes its own head. Also react-router (no client-side routes left), motion, vite-plugin-svgr,
and the SPA fallback (platform.web.spa.enabled=false): with the site server-rendered, forwarding a
mistyped URL to /index.html would answer with a blank admin shell and a 200 instead of the site's own
404 page.

WHAT GOT BETTER ON THE WAY, none of it visible. The category filter is a ?category= link, so every
filtered view is a URL you can send someone and a crawler can reach all forty items instead of the
twelve the default filter showed. The contact form is a form post: the enquiry is recorded before
delivery is attempted, and a refused relay re-renders the page with what the visitor typed still in
the boxes. The mobile menu is a <details> — the React version needed four effects to close on
navigation, close on Escape, stop the page behind it scrolling, and unmount (a panel parked off-screen
still extends the scrollable area, which is how you used to be able to scroll sideways and find the
menu); a new document cannot inherit an open menu.

THE PUBLIC SITE SHIPS 5 KB OF JAVASCRIPT, and works without it. The product cards are scroll-snap
strips, so the photos swipe on a phone and scroll with a trackpad unaided; gallery.js adds the arrows
and the dots, and creates them itself rather than having the template render controls that would sit
there dead.

Tailwind still needs its compiler, so npm remains a BUILD tool: the CLI compiles the templates into
static/css/site.css at process-classes (so `spring-boot:run` gets it too), and frontend/ now builds
only that stylesheet and the admin. The brand tokens are one file both stylesheets import — the
alternative was the shop front and the screen that edits it drifting a shade apart. The stylesheet URL
carries ?v=<sha>, because one hand-written CSS file has no content hash and a deploy has to be able to
tell a browser that what it cached is stale.

The admin is still React and is untouched, apart from losing the router it no longer needs. It is an
editor, not content.

PlatformContractTest stopped inheriting platform-starter-test's contract and restates it. The shared
version asserts that an unknown path forwards to the SPA shell, which is no longer true here, and its
test methods are package-private so it cannot be overridden. The platform should decide that assertion
from platform.web.spa.enabled — noted in the file.

9 new tests (46 total): every page's real title and og:url, the catalogue and the catering tables in
the HTML rather than fetched afterwards, server-side filtering, the 404, and that a crafted ?about=
link cannot put words of its own choosing in front of a customer.
This commit is contained in:
2026-07-26 16:07:01 -05:00
parent 5b1c837059
commit 70af2922f5
54 changed files with 2127 additions and 2398 deletions
-127
View File
@@ -1,127 +0,0 @@
import { useState } from 'react';
import { csrfHeader } from '@/lib/api';
type Status = 'idle' | 'sending' | 'sent' | 'error';
const ContactPage = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [status, setStatus] = useState<Status>('idle');
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('sending');
setError('');
try {
// csrfHeader() is empty unless security is switched on, so this posts the same as it always
// has on a deployment with no identity provider — and keeps working once one is configured,
// where an unaccompanied POST would otherwise be rejected.
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...csrfHeader() },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
setStatus('sent');
setFormData({ name: '', email: '', message: '' });
} catch (err) {
// Never claim success we did not get. Tell them, and give them the phone number.
setStatus('error');
setError(err instanceof Error ? err.message : 'Could not send the message.');
}
};
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">
Contact us
</h1>
</header>
{/* Contact Form Section */}
<div className="container mx-auto px-4 pb-16 md:pb-24">
<div className="max-w-2xl mx-auto bg-white p-8 md:p-10 rounded-3xl shadow-xs">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-2">Get in touch</h2>
<p className="text-bakery-700 mb-8">
Or call us: <a href="tel:+13097010660" className="underline underline-offset-4 hover:text-bakery-600">(309) 701-0660</a>
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
rows={5}
required
/>
</div>
<div className="flex flex-col items-center gap-4">
<button
type="submit"
disabled={status === 'sending'}
className="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors disabled:opacity-60"
>
{status === 'sending' ? 'Sending...' : 'Send message'}
</button>
{status === 'sent' && (
<p role="status" className="text-bakery-700">
Thanks. Your message is on its way, and we will get back to you.
</p>
)}
{status === 'error' && (
<p role="alert" className="text-center text-red-700">
{error} Please call us on{' '}
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
</p>
)}
</div>
</form>
</div>
</div>
</div>
);
};
export default ContactPage;
-59
View File
@@ -1,59 +0,0 @@
const HistoryPage = () => {
return (
<div className="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 story
</h1>
</header>
{/* Story */}
<div className="container mx-auto px-4 pb-16 md:pb-24">
<div className="max-w-2xl mx-auto space-y-12">
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">How it started</h2>
<p className="text-bakery-800 leading-relaxed">
Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown
Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep
enough tables that nobody feels rushed out the door.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">What we make</h2>
<p className="text-bakery-800 leading-relaxed">
We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls
and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and
paninis once the lunch crowd shows up.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">The cakes are the fun part</h2>
<p className="text-bakery-800 leading-relaxed">
Cakes and decorated cookies are made to order, which means we mostly bake whatever
Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a
retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for
the cross country team and for a bridal party. If you can describe it, we will have a go
at it.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">Around town</h2>
<p className="text-bakery-800 leading-relaxed">
We turn out for Christmas in the Village every year and for other civic events, and the
Princeville Civic Association counts us among the town&apos;s small businesses. Enjoy
Illinois and Discover Peoria have both pointed travelers our way. If you are one of them,
we open at 7:00am, Tuesday through Saturday.
</p>
</section>
</div>
</div>
</div>
);
};
export default HistoryPage;
-133
View File
@@ -1,133 +0,0 @@
import { Link } from 'react-router-dom';
import Logo from '@/components/Logo';
import { photo } from '@/lib/assets';
const HomePage = () => {
return (
<div>
{/* Hero — centred lockup on a sage wash. */}
<section className="relative flex items-center min-h-[78svh] py-20 md:py-28 bg-bakery-900 text-white overflow-hidden">
{/* Softened on purpose: the storefront's own painted sign sits right behind
the logo, so a sharp photo makes you read the name twice. scale-110 hides
the blur's feathered edges. */}
<img
src={photo('gallery/Outside.webp')}
alt=""
fetchPriority="high"
className="absolute inset-0 w-full h-full object-cover object-bottom blur-[3px] scale-110"
/>
{/* Sage wash rather than a neutral black scrim — the tint is the identity. */}
<div className="absolute inset-0 bg-bakery-900/80" />
<div className="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80" />
{/* w-full/min-w-0 keep this flex item from sizing to its max-content
width and blowing out the page on narrow screens. */}
<div className="relative container mx-auto px-4 w-full min-w-0">
<div className="max-w-3xl mx-auto text-center">
<Logo size="lg" linked={false} className="text-bakery-50 mb-10 justify-center" />
<p className="text-lg sm:text-xl text-bakery-100 mb-10 text-balance leading-relaxed">
A coffeehouse and bakery in downtown Princeville, Illinois.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
to="/products"
className="bg-bakery-50 hover:bg-white text-bakery-900 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition shadow-lg"
>
See the menu
</Link>
<a
href="#visit"
className="border border-bakery-200/60 hover:bg-bakery-50/10 text-bakery-50 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
>
Visit us
</a>
</div>
</div>
</div>
</section>
{/* Our Specialties */}
<section className="py-16 md:py-24 bg-bakery-50">
<div className="container mx-auto px-4">
<h2 className="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">What people come in for</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8">
{['Cinnamon Rolls', 'Sugar Cookies', 'Cakes'].map((item) => (
<div
key={item}
className="group text-center bg-white rounded-3xl overflow-hidden shadow-xs hover:shadow-lg hover:-translate-y-1 transition duration-300"
>
<div className="overflow-hidden">
<img
src={photo(`gallery/${item}.webp`)}
alt={item}
width={600}
height={400}
loading="lazy"
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<h3 className="font-adbhashitha text-xl md:text-2xl text-bakery-800 py-6 tracking-wide">{item}</h3>
</div>
))}
</div>
</div>
</section>
{/* About Us */}
<section className="py-16 md:py-24 bg-bakery-800 text-white">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto text-center">
<h2 className="font-adbhashitha text-3xl md:text-4xl mb-8" style={{ letterSpacing: '0.01em' }}>Our story</h2>
<p className="text-base md:text-lg text-bakery-100 mb-8 leading-relaxed">
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
</p>
<Link to="/history" className="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
Read our story
</Link>
</div>
</div>
</section>
{/* Hours & Contact */}
<section id="visit" className="py-16 md:py-24 bg-bakery-50 scroll-mt-24">
<div className="container mx-auto px-4">
<h2 className="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">Visit us</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-4xl mx-auto">
<div className="bg-white rounded-3xl p-6 md:p-8">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Our hours</h2>
<ul className="space-y-3 text-bakery-800">
<li className="flex justify-between gap-4">
<span>Tuesday Friday</span>
<span className="font-medium whitespace-nowrap">7:00am 2:00pm</span>
</li>
<li className="flex justify-between gap-4">
<span>Saturday</span>
<span className="font-medium whitespace-nowrap">7:00am 12:00pm</span>
</li>
<li className="flex justify-between gap-4">
<span>Sunday Monday</span>
<span className="font-medium">Closed</span>
</li>
</ul>
</div>
<div className="bg-white rounded-3xl p-6 md:p-8">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Find us</h2>
<address className="not-italic space-y-3 text-bakery-800">
<p>215 E Main Street<br />Princeville, IL 61559</p>
<p>
<a href="tel:+13097010660" className="hover:text-bakery-600 underline underline-offset-4">(309) 701-0660</a>
</p>
<p className="break-words">
<a href="mailto:[email protected]" className="hover:text-bakery-600 underline underline-offset-4">contact@itsthevine.com</a>
</p>
</address>
</div>
</div>
</div>
</section>
</div>
);
};
export default HomePage;
-30
View File
@@ -1,30 +0,0 @@
import { Link } from 'react-router-dom';
const NotFoundPage = () => (
<div className="bg-bakery-50">
<div className="container mx-auto px-4 py-24 md:py-32 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">
We could not find that page
</h1>
<p className="text-bakery-800 mb-10">
It may have moved. The menu, our story, and how to reach us are all still here.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
to="/"
className="bg-bakery-600 hover:bg-bakery-700 text-white px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
>
Back home
</Link>
<Link
to="/products"
className="border border-bakery-300 hover:bg-bakery-100 text-bakery-700 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
>
See the menu
</Link>
</div>
</div>
</div>
);
export default NotFoundPage;
-94
View File
@@ -1,94 +0,0 @@
import { useEffect, useState } from 'react';
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) => (
<button
key={category}
type="button"
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}
</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 — deliberately unanimated. Filtering used to run a `layout` reflow plus
an enter/exit fade on every card, which on a 40-card grid reads as the page lurching
rather than responding. Swapping the list outright is instant, and the only motion
left is the shadow on hover. */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<div
key={product.id}
className="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg"
>
{/* 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>
</div>
))}
</div>
</div>
</div>
);
};
export default ProductsPage;