diff --git a/Dockerfile b/Dockerfile
index c9a05ff..b967ab8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,5 +36,8 @@ ARG GIT_SHA=unknown
LABEL org.opencontainers.image.title="itsthevine" \
org.opencontainers.image.source="https://git.thebennett.net/thevine/itsthevine" \
org.opencontainers.image.revision="${GIT_SHA}"
+# Also the cache-buster on the stylesheet URL: one hand-written CSS file has no content hash in its
+# name, so a deploy has to tell the browser that what it cached is stale (see site.build).
+ENV GIT_SHA=${GIT_SHA}
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
diff --git a/README.md b/README.md
index a41d880..b5831dc 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,74 @@
# The Vine Coffeehouse + Bakery — itsthevine.com
-Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot serving a Vite/React SPA,
-on [the Bennett platform](https://git.thebennett.net/austin/platform).
+Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot rendering its own pages with
+Thymeleaf, on [the Bennett platform](https://git.thebennett.net/austin/platform).
-Previously a Next.js app on Cloudflare, then self-hosted; the look is unchanged.
+Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered. The look has
+not changed through any of it.
## Shape
| | |
|---|---|
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
-| Frontend | Vite + React 19 + TypeScript + Tailwind v4, served from the jar |
+| Pages | Thymeleaf, `src/main/resources/templates` — **no JavaScript** except one 100-line file for the product-card arrows |
+| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css` |
+| Admin | the one React screen that is left, served at `/admin` only |
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
| Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image |
| Deploy | Gitea CI → image → Watchtower → Caddy |
+### Why server-rendered
+
+The pages are content: a menu, a story, opening hours, a price list. Rendering them in the browser meant
+shipping a router and a component tree to show them, and it meant `PageMetaController` — a class whose
+only job was to splice per-page `
` and OG tags into one shell with regular expressions, because a
+crawler or a link-preview scraper got nothing useful otherwise. A page that is rendered on the server
+writes its own head, so that whole mechanism is deleted rather than ported. The category filter is a
+`?category=` link instead of a click handler, which also makes every filtered view a URL you can send
+someone, and the contact form is a form post.
+
+`platform.web.spa.enabled=false` follows from that: the platform's fallback forwards extension-less paths
+to `/index.html`, which now holds nothing but the admin. `SiteController` maps `/admin` to it explicitly.
+
## What the server owns
-The SPA renders; it doesn't decide anything.
+Everything. The pages arrive complete.
- **`/api/products`**, **`/api/categories`** — the catalogue, its curated order, the category filter
and the absolute image URLs. This was a TypeScript array shipped to every visitor; it's now a table
(`V2__products.sql`) read through `ProductCatalog`.
-- **`/api/catering`** — the goodie box and catering price tables (Office, Parties, Weddings): the
+- **`/catering`** — the goodie box and catering page. Each table is rendered twice from the same model
+ and CSS shows one: a real `
-);
-
-/**
- * The admin sits outside the public chrome deliberately. It isn't a page you'd browse to — the nav
- * would offer a signed-in editor links away from unsaved work, and the opening hours in the footer
- * are noise on a screen whose whole job is the catalogue.
- */
-const AdminLayout = () => (
-
);
export default App;
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
deleted file mode 100644
index 2b42224..0000000
--- a/frontend/src/components/Footer.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Link } from 'react-router-dom';
-import Logo from './Logo';
-
-const Footer = () => {
- return (
-
- );
-};
-
-export default Footer;
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
deleted file mode 100644
index bd3ab89..0000000
--- a/frontend/src/components/Header.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { useEffect, useState } from 'react';
-import { motion, AnimatePresence } from 'motion/react';
-import { Link, useLocation } from 'react-router-dom';
-import Logo from './Logo';
-
-const navItems = [
- { label: 'Our Products', href: '/products' },
- { label: 'Our Story', href: '/history' },
- { label: 'Contact', href: '/contact' },
-];
-
-const Header = () => {
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
- const { pathname } = useLocation();
-
- // Close on navigation — without this the panel stays up over the page you just opened.
- useEffect(() => setIsMobileMenuOpen(false), [pathname]);
-
- // Escape closes it, and the page behind it doesn't scroll while it's up.
- useEffect(() => {
- if (!isMobileMenuOpen) return;
- const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsMobileMenuOpen(false); };
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = 'hidden';
- window.addEventListener('keydown', onKey);
- return () => {
- document.body.style.overflow = previousOverflow;
- window.removeEventListener('keydown', onKey);
- };
- }, [isMobileMenuOpen]);
-
- return (
- <>
-
-
-
- {/* Logo */}
-
- {/* Desktop Navigation */}
-
-
- {/* Mobile menu button — the same control opens and closes, so the bar never
- disappears out from under your thumb. */}
-
-
-
-
-
- {/* Mobile navigation. Three things here are load-bearing:
-
- It lives OUTSIDE . The header carries `backdrop-blur`, and a backdrop-filter
- makes an element a containing block for fixed-position descendants — so a `fixed` panel
- nested inside it resolves against the 80px header box, not the viewport, and gets
- clipped to a sliver.
-
- It starts BELOW the bar (`top-20`) instead of covering it, so the logo and the toggle
- stay put and the panel needs no second copy of either. One logo, one position, every
- breakpoint.
-
- It must UNMOUNT when closed: a panel parked off-screen still extends the scrollable
- area, which is what used to let you scroll sideways and find the menu. */}
-
- {isMobileMenuOpen && (
-
-
-
- )}
-
- >
- );
-};
-
-export default Header;
diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx
deleted file mode 100644
index 6c8a7ce..0000000
--- a/frontend/src/components/Logo.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Link } from 'react-router-dom';
-import Logo_R from '@/assets/logo_R.svg?react';
-import Logo_L from '@/assets/logo_L.svg?react';
-
-interface LogoProps {
- /** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */
- className?: string;
- size?: 'sm' | 'lg';
- /** The hero sits on the homepage, where a link back to "/" is pointless. */
- linked?: boolean;
-}
-
-// The lockup: branch · "The Vine" over "Coffeehouse + Bakery" · branch.
-// Branch heights track the two-line wordmark so the marks read as part of it.
-const SIZES = {
- sm: {
- branch: 'w-12 h-12 sm:w-14 sm:h-14',
- name: 'text-xl sm:text-2xl md:text-3xl',
- tag: 'text-[0.6rem] sm:text-xs tracking-[0.18em]',
- gap: 'gap-1.5 sm:gap-2',
- },
- lg: {
- branch: 'w-20 h-20 sm:w-28 sm:h-28',
- name: 'text-4xl sm:text-5xl md:text-6xl',
- tag: 'text-xs sm:text-base tracking-[0.2em]',
- gap: 'gap-2 sm:gap-4',
- },
-};
-
-const Logo: React.FC = ({ className = 'text-bakery-700', size = 'sm', linked = true }) => {
- const s = SIZES[size];
- const inner = (
- <>
-
-
-
- The Vine
-
-
- Coffeehouse + Bakery
-
-
-
- >
- );
-
- const classes = `flex items-center ${s.gap} min-w-0 shrink ${className}`;
-
- if (!linked) {
- return (
-
- {inner}
-
- );
- }
-
- return (
-
- {inner}
-
- );
-};
-
-export default Logo;
diff --git a/frontend/src/components/ProductGallery.tsx b/frontend/src/components/ProductGallery.tsx
deleted file mode 100644
index 95016ad..0000000
--- a/frontend/src/components/ProductGallery.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useRef, useState } from 'react';
-
-interface ProductGalleryProps {
- images: string[];
- alt: string;
-}
-
-const Chevron = ({ direction }: { direction: 'left' | 'right' }) => (
-
-);
-
-/**
- * The square photo on a product card, with arrows and dots 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.
- */
-/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */
-const SWIPE_THRESHOLD = 40;
-
-const ProductGallery: React.FC = ({ images, alt }) => {
- const [index, setIndex] = useState(0);
-
- // Filtering swaps the product under a reused component instance, so a stale index can point
- // past the new list — every frame then renders at opacity-0 and the card goes blank. Reset
- // during render (the React-sanctioned way to derive state from props) rather than in an effect,
- // so the correct frame paints on the first pass instead of flashing an empty square.
- const [renderedFor, setRenderedFor] = useState(images);
- if (renderedFor !== images) {
- setRenderedFor(images);
- setIndex(0);
- }
-
- const many = images.length > 1;
- const active = index < images.length ? index : 0;
-
- const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
-
- // Swipe on touch devices and arrow keys — the react-awesome-slider this replaced had swipe, and
- // the products page is browsed mostly on phones. Vertical drags are left alone so the page still
- // scrolls through the card.
- const touchStart = useRef<{ x: number; y: number } | null>(null);
- const onTouchStart = (e: React.TouchEvent) => {
- const t = e.touches[0];
- touchStart.current = { x: t.clientX, y: t.clientY };
- };
- const onTouchEnd = (e: React.TouchEvent) => {
- const start = touchStart.current;
- touchStart.current = null;
- if (!start || !many) return;
- const t = e.changedTouches[0];
- const dx = t.clientX - start.x;
- const dy = t.clientY - start.y;
- if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return;
- step(dx < 0 ? 1 : -1);
- };
-
- const arrowClass =
- 'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white';
-
- return (
-
{
- if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
- if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
- } : undefined}
- tabIndex={many ? 0 : undefined}
- role={many ? 'group' : undefined}
- aria-roledescription={many ? 'carousel' : undefined}
- aria-label={many ? `${alt} — ${images.length} photos` : undefined}
- >
- {images.map((src, i) => (
-
- ))}
-
- {many && (
- <>
-
-
-
- {/* Dots: how many photos there are, and which one you're on. */}
-
- {images.map((src, i) => (
-
- >
- )}
-
- );
-};
-
-export default ProductGallery;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 8994160..40693f5 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,85 +1,13 @@
+/*
+ * The admin screen's stylesheet.
+ *
+ * The brand itself lives in ./tokens.css, shared with the server-rendered
+ * site's stylesheet — one palette, one set of fonts, and no drift between the shop front and the
+ * screen that edits it. Everything else the admin needs comes from utility classes in the React
+ * source, which Tailwind finds by scanning it.
+ *
+ * The fonts are served by Spring from /fonts rather than bundled here, so both stylesheets can name
+ * the same URL.
+ */
@import "tailwindcss";
-
-/* Brand fonts ship with the app rather than coming from Google — same look, no third-party request
- on every page load. Raleway is the variable latin subset. */
-@font-face {
- font-family: 'Raleway';
- src: url('./fonts/raleway-latin.woff2') format('woff2');
- font-weight: 100 900;
- font-style: normal;
- font-display: swap;
-}
-@font-face {
- font-family: 'AdBhashitha';
- src: url('./fonts/AdBhashitha.woff') format('woff');
- font-display: swap;
-}
-@font-face {
- font-family: 'LeJour Script';
- src: url('./fonts/LeJour-Script.woff') format('woff');
- font-display: swap;
-}
-
-@theme {
- /* Sage & Cream. 500 is the signature sage; 600+ are the darker tones that white text can actually
- sit on (500 on white is only 3.6:1 — too low). */
- --color-bakery-50: #faf7f0;
- --color-bakery-100: #f0efe3;
- --color-bakery-200: #dde0cc;
- --color-bakery-300: #c3cbae;
- --color-bakery-400: #a2ae8b;
- --color-bakery-500: #7c8b6b;
- --color-bakery-600: #5f6f52;
- --color-bakery-700: #4a5740;
- --color-bakery-800: #37412f;
- --color-bakery-900: #232b1e;
-
- --font-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif;
- --font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif;
- --font-lejour: 'LeJour Script', ui-serif, Georgia, cursive;
-}
-
-html {
- scroll-behavior: smooth;
- /* Nothing on this site is meant to scroll sideways. */
- overflow-x: hidden;
-}
-
-body {
- background-color: var(--color-bakery-50);
- color: var(--color-bakery-900);
- font-family: var(--font-sans);
- overflow-x: hidden;
- opacity: 0;
- animation: fadeIn 0.5s ease-in forwards;
-}
-
-@media (prefers-reduced-motion: reduce) {
- html { scroll-behavior: auto; }
- body { animation: none; opacity: 1; }
-}
-
-@keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* In @layer components, matching the old site. That ordering matters: a utility like `px-4` — which
- the markup applies alongside `container` on nearly every section — has to win over this, or every
- gutter on the site silently widens. */
-@layer components {
- .container {
- @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
- }
-}
-
-::selection {
- background-color: var(--color-bakery-300);
- color: var(--color-bakery-900);
-}
+@import "./tokens.css";
diff --git a/frontend/src/lib/assets.ts b/frontend/src/lib/assets.ts
deleted file mode 100644
index 6e6ba6f..0000000
--- a/frontend/src/lib/assets.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Photos live in the public MinIO bucket, not in the app image — 50 MB of JPEGs has no business
- * inside a container we redeploy on every commit, and the bucket serves them with a year-long
- * cache. Small brand assets (logo marks, favicons) stay local so first paint needs nothing external.
- */
-const BASE = (import.meta.env.VITE_ASSET_BASE ?? 'https://s3.thebennett.net/itsthevine').replace(/\/$/, '');
-
-/**
- * `photo('products/scones.webp')` -> absolute bucket URL.
- *
- * Segments are encoded individually: some gallery files have spaces in their names ("Cinnamon
- * Rolls.webp") and a raw space in a URL doesn't fetch.
- */
-export function photo(key: string): string {
- const path = key.replace(/^\//, '').split('/').map(encodeURIComponent).join('/');
- return `${BASE}/images/${path}`;
-}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index e2d123a..a74ce78 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,13 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
+// No BrowserRouter: this shell is served at /admin and nowhere else, so there is nothing to route.
createRoot(document.getElementById('root')!).render(
-
-
-
+ ,
);
diff --git a/frontend/src/pages/Contact.tsx b/frontend/src/pages/Contact.tsx
deleted file mode 100644
index 0a4f66e..0000000
--- a/frontend/src/pages/Contact.tsx
+++ /dev/null
@@ -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('idle');
- const [error, setError] = useState('');
-
- const handleChange = (e: React.ChangeEvent) => {
- 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 (
-
- 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.
-
-
-
-
-
What we make
-
- 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.
-
-
-
-
-
The cakes are the fun part
-
- 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.
-
-
-
-
-
Around town
-
- 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'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.
-
-
-
-
-
- );
-};
-
-export default HistoryPage;
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
deleted file mode 100644
index 00c59db..0000000
--- a/frontend/src/pages/Home.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import { Link } from 'react-router-dom';
-import Logo from '@/components/Logo';
-import { photo } from '@/lib/assets';
-
-const HomePage = () => {
- return (
-
- {/* Hero — centred lockup on a sage wash. */}
-
- {/* 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. */}
-
- {/* Sage wash rather than a neutral black scrim — the tint is the identity. */}
-
-
-
- {/* w-full/min-w-0 keep this flex item from sizing to its max-content
- width and blowing out the page on narrow screens. */}
-
-
-
-
- A coffeehouse and bakery in downtown Princeville, Illinois.
-
- 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.
-
- It may have moved. The menu, our story, and how to reach us are all still here.
-
-
-
- Back home
-
-
- See the menu
-
-
-
-
-);
-
-export default NotFoundPage;
diff --git a/frontend/src/pages/Products.tsx b/frontend/src/pages/Products.tsx
deleted file mode 100644
index e3346f5..0000000
--- a/frontend/src/pages/Products.tsx
+++ /dev/null
@@ -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(['All']);
- const [selectedCategory, setSelectedCategory] = useState('All');
- const [products, setProducts] = useState([]);
- 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 (
-
- {/* Page header */}
-
-
- Our products
-
-
-
- {/* Products Section */}
-
- {/* Categories */}
-
- {categories.map((category) => (
-
- ))}
-
-
- {failed && (
-
- We could not load the menu just now. Please refresh, or call us on{' '}
- (309) 701-0660.
-
- )}
-
- {/* 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. */}
-
- {products.map((product) => (
-
- {/* Image Container */}
-
-
-
-
- {/* Content */}
-
-
- {product.name}
-
-
- {product.category}
-
-
-
- ))}
-
-
-
- );
-};
-
-export default ProductsPage;
diff --git a/frontend/src/tokens.css b/frontend/src/tokens.css
new file mode 100644
index 0000000..2940a4b
--- /dev/null
+++ b/frontend/src/tokens.css
@@ -0,0 +1,140 @@
+/*
+ * The brand: the sage-and-cream palette, the three fonts, and the base element rules.
+ *
+ * Shared deliberately. Two stylesheets are compiled from this — the server-rendered site
+ * (src/main/tailwind/site.css, scanning the Thymeleaf templates) and the admin SPA
+ * (frontend/src/index.css, scanning the React source) — and if the tokens were copied into both, the
+ * two halves of the same site would drift a shade apart the first time one of them was edited.
+ *
+ * Fonts are served by Spring from /fonts, not bundled by Vite, so both stylesheets can name the same
+ * URL and the browser caches one copy.
+ */
+
+@font-face {
+ font-family: 'Raleway';
+ src: url('/fonts/raleway-latin.woff2') format('woff2');
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'AdBhashitha';
+ src: url('/fonts/AdBhashitha.woff') format('woff');
+ font-display: swap;
+}
+@font-face {
+ font-family: 'LeJour Script';
+ src: url('/fonts/LeJour-Script.woff') format('woff');
+ font-display: swap;
+}
+
+@theme {
+ /* Sage & Cream. 500 is the signature sage; 600+ are the darker tones that white text can actually
+ sit on (500 on white is only 3.6:1 — too low). */
+ --color-bakery-50: #faf7f0;
+ --color-bakery-100: #f0efe3;
+ --color-bakery-200: #dde0cc;
+ --color-bakery-300: #c3cbae;
+ --color-bakery-400: #a2ae8b;
+ --color-bakery-500: #7c8b6b;
+ --color-bakery-600: #5f6f52;
+ --color-bakery-700: #4a5740;
+ --color-bakery-800: #37412f;
+ --color-bakery-900: #232b1e;
+
+ --font-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif;
+ --font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif;
+ --font-lejour: 'LeJour Script', ui-serif, Georgia, cursive;
+}
+
+html {
+ scroll-behavior: smooth;
+ /* Nothing on this site is meant to scroll sideways. */
+ overflow-x: hidden;
+}
+
+body {
+ background-color: var(--color-bakery-50);
+ color: var(--color-bakery-900);
+ font-family: var(--font-sans);
+ overflow-x: hidden;
+ opacity: 0;
+ animation: fadeIn 0.5s ease-in forwards;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ body { animation: none; opacity: 1; }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* In @layer components, matching the old site. That ordering matters: a utility like `px-4` — which
+ the markup applies alongside `container` on nearly every section — has to win over this, or every
+ gutter on the site silently widens. */
+@layer components {
+ .container {
+ @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
+ }
+
+ /*
+ * The two branch marks of the wordmark, as masks rather than inline SVG.
+ *
+ * They have to take their colour from the surrounding text — sage in the header, cream on the hero
+ * and in the footer — which an cannot do. React solved that by inlining them through svgr,
+ * but these files are 31 KB each and the lockup appears up to three times on a page: inlining them
+ * in server-rendered HTML would add ~190 KB to every response. A mask over `currentColor` keeps the
+ * tinting, keeps the files cacheable, and costs two requests once.
+ */
+ .mark {
+ background-color: currentColor;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ mask-position: center;
+ -webkit-mask-size: contain;
+ mask-size: contain;
+ }
+ .mark-r {
+ -webkit-mask-image: url('/images/logo_R.svg');
+ mask-image: url('/images/logo_R.svg');
+ }
+ .mark-l {
+ -webkit-mask-image: url('/images/logo_L.svg');
+ mask-image: url('/images/logo_L.svg');
+ }
+
+ /*
+ * The photo strip on a product card scrolls, but a scrollbar across the bottom of a photo is not part
+ * of the design. Hiding it is safe here because the strip is not the only way through the photos: it
+ * snaps, it takes arrow keys, and the arrows and dots are on top of it.
+ */
+ .no-scrollbar {
+ scrollbar-width: none;
+ }
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+/* The mobile menu is a ; its default disclosure triangle would sit next to the hamburger. */
+summary {
+ list-style: none;
+}
+summary::-webkit-details-marker {
+ display: none;
+}
+
+::selection {
+ background-color: var(--color-bakery-300);
+ color: var(--color-bakery-900);
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index c52f554..43ed851 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -5,7 +5,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
- "types": ["vite/client", "vite-plugin-svgr/client", "node"],
+ "types": ["vite/client", "node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index d081fd7..0d7c301 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,26 +1,31 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
-import svgr from 'vite-plugin-svgr';
import path from 'node:path';
+/**
+ * Builds the admin screen, and only that. The public site is server-rendered by Spring and never comes
+ * through here — its stylesheet is compiled from the Thymeleaf templates by the Tailwind CLI
+ * (`npm run build:css`).
+ *
+ * vite-plugin-svgr went with the public pages: the logo marks were inlined through it so they could
+ * take their colour from the surrounding text, and the server-rendered header does that with a CSS
+ * mask instead.
+ */
export default defineConfig({
- // svgr keeps the `import Logo from './x.svg?react'` style the old @svgr/webpack setup used, so the
- // logo marks stay inline SVG and inherit currentColor.
- plugins: [react(), tailwindcss(), svgr()],
+ plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 2024,
- // Backend on :8080 during development; the built SPA is served by Spring, same origin.
+ // Backend on :8080 during development; the built admin is served by Spring, same origin.
proxy: {
'/api': 'http://localhost:8080',
},
},
build: {
outDir: 'dist',
- // The photos live in MinIO, so what's left is small — a warning here would mean a real regression.
chunkSizeWarningLimit: 600,
},
});
diff --git a/pom.xml b/pom.xml
index 3b2f49b..14e7ccd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -58,6 +58,13 @@
net.thebennett.platformplatform-starter-data
+
+
+ org.springframework.boot
+ spring-boot-starter-thymeleaf
+ net.thebennett.platformplatform-starter-contact
@@ -117,10 +124,28 @@
org.springframework.bootspring-boot-maven-plugin
-
+
com.github.eirslettfrontend-maven-plugin
+
+
+
+ npm-build-css
+ process-classes
+ npm
+ run build:css
+
+ org.apache.maven.plugins
diff --git a/src/main/java/com/itsthevine/web/ContactController.java b/src/main/java/com/itsthevine/web/ContactController.java
index b13095e..c679c77 100644
--- a/src/main/java/com/itsthevine/web/ContactController.java
+++ b/src/main/java/com/itsthevine/web/ContactController.java
@@ -4,31 +4,28 @@ import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
-import com.itsthevine.web.domain.ContactEnquiry;
-import com.itsthevine.web.domain.ContactEnquiryRepository;
-
import net.thebennett.platform.contact.ContactException;
-import net.thebennett.platform.contact.ContactService;
-import net.thebennett.platform.contact.Enquiry;
/**
- * The contact form. Keeps the response shape the old Next route used ({@code {ok:true}} /
+ * The contact form as JSON. Keeps the response shape the old Next route used ({@code {ok:true}} /
* {@code {error:"..."}}), because the error text is shown to the visitor as-is.
+ *
+ *
The page's own form posts to {@code /contact} and renders a page rather than reading this. Both
+ * go through {@link Enquiries}, so there is one order of operations for taking an enquiry.
*/
@RestController
@RequestMapping("/api/contact")
public class ContactController {
- private final ContactService contact;
- private final ContactEnquiryRepository enquiries;
+ private final Enquiries enquiries;
- public ContactController(ContactService contact, ContactEnquiryRepository enquiries) {
- this.contact = contact;
+ public ContactController(Enquiries enquiries) {
this.enquiries = enquiries;
}
@@ -36,18 +33,7 @@ public class ContactController {
@PostMapping
public ResponseEntity