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.
@@ -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"]
|
||||
|
||||
@@ -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 `<title>` 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 `<table>` on a wide screen, because that is what a price list is and a screen
|
||||
reader then announces the size and the item together; stacked cards on a phone, because a four-column
|
||||
price table there is either illegible or a sideways scroll, and this page is mostly read on phones.
|
||||
- **`/api/catering`** — the same tables as JSON: the
|
||||
columns, the prices already written the way they should be read, the entries under each column, and
|
||||
the small print. These came from the bakery as a spreadsheet and are stored as one (`V4__catering.sql`,
|
||||
read through `CateringMenu`) rather than as markup, because the prices move and the last line of that
|
||||
spreadsheet says the tables are "mostly just an idea for people". `Money` is the only thing that
|
||||
decides what a typed price means or how it prints. A table with no columns or no lines is left off the
|
||||
public response — adding a table and filling it in are two separate acts in the admin, and the gap
|
||||
between them shouldn't put a bare heading on the live page. *(No public page renders this yet.)*
|
||||
between them shouldn't put a bare heading on the live page.
|
||||
- **`/contact`** — the form posts here and gets a page back. It renders rather than redirects on failure,
|
||||
so a refused relay comes back with what the visitor typed still in the boxes: they wrote it once, and
|
||||
the failure is ours. `/api/contact` still exists and answers JSON; both go through `Enquiries`, so
|
||||
there is one order of operations for taking an enquiry.
|
||||
- **`/api/contact`** — validates, **records the enquiry**, emails it, then fans out to the n8n hub.
|
||||
Recorded before sending on purpose: a relay outage costs a notification, not the enquiry. Undelivered
|
||||
ones are `enquiry.delivered = false`. Validation and delivery come from `platform-starter-contact`,
|
||||
shared with the other sites.
|
||||
- **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used
|
||||
to server-render these; a plain SPA would hand crawlers and link-preview scrapers one generic shell.
|
||||
- **Per-page metadata** — each route states its own title and description in `SiteController`, next to
|
||||
the handler that serves it, and `fragments/head.html` lays them out. `SiteControllerTest` asserts the
|
||||
real `<title>` of every page.
|
||||
|
||||
## /admin
|
||||
|
||||
**The last React in the repo.** The public pages are server-rendered; this screen is a Vite/React app
|
||||
because it is not content — it is an editor, and the instant-feedback editing (reorder that applies
|
||||
before the network answers, a whole price table arranged on screen and saved in one go) is the point of
|
||||
it. Everything under `frontend/` builds only this, plus the site's stylesheet.
|
||||
|
||||
The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or
|
||||
reorder the category filters. Nothing there needs a deploy or a migration — which is the point, since
|
||||
the person adding a cake is the person who baked it.
|
||||
@@ -72,14 +102,22 @@ history. EXIF (including GPS from phone photos) is stripped by the re-encode.
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
# backend (needs Postgres on :5432 with an itsthevine database)
|
||||
mvn spring-boot:run
|
||||
# the whole site (needs Postgres on :5432 with an itsthevine database)
|
||||
mvn spring-boot:run # http://localhost:8080
|
||||
|
||||
# frontend, proxies /api to :8080
|
||||
cd frontend && npm install && npm run dev # http://localhost:2024
|
||||
# just the stylesheet, while editing templates — watches and recompiles
|
||||
cd frontend && npm install && npx tailwindcss -i site.css -o ../target/classes/static/css/site.css --watch
|
||||
|
||||
# the admin screen, proxying /api to :8080
|
||||
cd frontend && npm run dev # http://localhost:2024/admin
|
||||
```
|
||||
|
||||
Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`.
|
||||
`mvn spring-boot:run` compiles the stylesheet on the way (the Tailwind step is bound to
|
||||
`process-classes` for exactly that reason). `-DskipFrontend=true` skips both frontend steps for a fast
|
||||
backend loop — the pages then render **unstyled** until you build the CSS once.
|
||||
|
||||
Templates are cached by default, so a template edit needs a restart; add
|
||||
`spring.thymeleaf.cache=false` to a local run if you are editing markup.
|
||||
|
||||
Tests need Docker (Testcontainers):
|
||||
|
||||
@@ -96,7 +134,8 @@ mvn verify
|
||||
| `CONTACT_TO` / `CONTACT_FROM` | enquiry recipient and envelope sender |
|
||||
| `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission |
|
||||
| `SITE_BASE_URL` | absolute base for `og:url` |
|
||||
| `VITE_ASSET_BASE` / `site.assets.base-url` | photo bucket |
|
||||
| `site.assets.base-url` | photo bucket. Server-side only now — the browser is handed finished URLs |
|
||||
| `GIT_SHA` | passed by the image build; becomes `?v=` on the stylesheet so a deploy invalidates the cached CSS |
|
||||
| `SECURITY_MODE` | `OIDC` turns on Authentik login **and brings `/admin` into existence**. Unset = brochure site, no admin |
|
||||
| `STORAGE_ENDPOINT` / `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` / `STORAGE_BUCKET` | MinIO, for admin photo uploads. Blank endpoint leaves storage switched off |
|
||||
|
||||
|
||||
@@ -5,22 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png">
|
||||
<link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png">
|
||||
<!-- Preconnect to the photo bucket so product images start loading a round-trip sooner. -->
|
||||
<link rel="preconnect" href="https://s3.thebennett.net" crossorigin>
|
||||
<!-- The wordmark is set in these; without preloading they arrive late and the logo visibly reflows. -->
|
||||
<link rel="preload" as="font" type="font/woff2" href="/src/fonts/raleway-latin.woff2" crossorigin>
|
||||
<link rel="preload" as="image" href="https://s3.thebennett.net/itsthevine/images/gallery/Outside.webp" fetchpriority="high">
|
||||
<!-- PageMetaController rewrites the title and the four meta tags below per route, so crawlers and
|
||||
link-preview scrapers get real per-page metadata instead of one generic shell. Keep the
|
||||
attribute order and quoting as-is — it matches on them. -->
|
||||
<title>The Vine Coffeehouse + Bakery</title>
|
||||
<meta name="description" content="A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.">
|
||||
<meta property="og:title" content="The Vine Coffeehouse + Bakery">
|
||||
<meta property="og:description" content="A locally owned coffeehouse and bakery in downtown Princeville, IL.">
|
||||
<meta property="og:url" content="https://itsthevine.com">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
|
||||
<!-- The shell for /admin, and nothing else. The public pages are server-rendered Thymeleaf now, so
|
||||
this file no longer carries metadata for crawlers, and PageMetaController — which used to rewrite
|
||||
it per route with regular expressions — is gone. -->
|
||||
<meta name="robots" content="noindex">
|
||||
<title>The Vine — admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build:css": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --minify",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"motion": "12.42.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "7.18.1"
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.3",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
@@ -22,7 +22,6 @@
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "^7.0.0",
|
||||
"vite": "^8.1.3",
|
||||
"vite-plugin-svgr": "^5.0.0"
|
||||
"vite": "^8.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* The stylesheet for the server-rendered site.
|
||||
*
|
||||
* Compiled by the Tailwind CLI (`npm run build:css`) straight into target/classes/static/css: it is a
|
||||
* source file, not a resource, and the generated stylesheet belongs in the build output rather than in
|
||||
* src/main/resources next to it.
|
||||
*
|
||||
* It lives in this directory, beside the admin's stylesheet, because Tailwind resolves `@import
|
||||
* "tailwindcss"` by walking up from the CSS file looking for node_modules — and node_modules is here.
|
||||
* So this directory is the whole asset pipeline: one Tailwind, two stylesheets, one of them for pages
|
||||
* that contain no JavaScript at all.
|
||||
*
|
||||
* @source points Tailwind at the templates, and at gallery.js — the product-card arrows are created in
|
||||
* script, so their classes are only written down there. A utility exists in the output only if Tailwind
|
||||
* saw it in one of these files, which is why a class name must never be assembled from pieces at
|
||||
* runtime.
|
||||
*/
|
||||
@import "tailwindcss";
|
||||
@import "./src/tokens.css";
|
||||
|
||||
@source "../src/main/resources/templates";
|
||||
@source "../src/main/resources/static/js";
|
||||
@@ -1,66 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import HomePage from '@/pages/Home';
|
||||
import ProductsPage from '@/pages/Products';
|
||||
import HistoryPage from '@/pages/History';
|
||||
import ContactPage from '@/pages/Contact';
|
||||
import NotFoundPage from '@/pages/NotFound';
|
||||
import AdminPage from '@/pages/Admin';
|
||||
|
||||
/**
|
||||
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
|
||||
* just opened. Anchors like /#visit still need to work, so only reset when there isn't one.
|
||||
* What's left of the React app: the admin screen, and nothing else.
|
||||
*
|
||||
* The shop front is server-rendered Thymeleaf now, so there are no client-side routes to route
|
||||
* between — react-router went with the pages it used to switch. Spring serves this shell at /admin and
|
||||
* only at /admin; every other URL is a page in src/main/resources/templates.
|
||||
*
|
||||
* The admin sits outside the public chrome deliberately: 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 ScrollToTop = () => {
|
||||
const { pathname, hash } = useLocation();
|
||||
useEffect(() => {
|
||||
// 'instant' overrides the page's scroll-behavior:smooth, which is meant for the #visit
|
||||
// anchor, not for landing on a new page.
|
||||
if (!hash) window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}, [pathname, hash]);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The shop front: the nav, the footer, and the pages a customer sees. */
|
||||
const PublicLayout = () => (
|
||||
<div className="min-h-screen bg-bakery-50 flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 = () => (
|
||||
<div className="min-h-screen bg-bakery-50">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
<div className="min-h-screen bg-bakery-50">
|
||||
<AdminPage />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Logo from './Logo';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="bg-bakery-900 text-bakery-100">
|
||||
<div className="container mx-auto px-4 py-14">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||
{/* Logo */}
|
||||
<div className="col-span-1 md:col-span-2 flex items-start">
|
||||
<Logo className="text-bakery-50" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div>
|
||||
<h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link to="/products" className="hover:text-white transition">Our Products</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/history" className="hover:text-white transition">Our Story</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/contact" className="hover:text-white transition">Contact</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div>
|
||||
<h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Visit</h3>
|
||||
<address className="not-italic space-y-2">
|
||||
<p>215 E Main Street<br />Princeville, IL 61559</p>
|
||||
<p><a href="tel:+13097010660" className="hover:text-white transition">(309) 701-0660</a></p>
|
||||
<p className="break-words">
|
||||
<a href="mailto:[email protected]" className="hover:text-white transition">contact@itsthevine.com</a>
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-12 text-center text-sm text-bakery-300">
|
||||
<p>© {new Date().getFullYear()} The Vine Coffeehouse + Bakery</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -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 (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between gap-2 h-20 md:h-24">
|
||||
{/* Logo */}
|
||||
<Logo className="text-bakery-700" />
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu button — the same control opens and closes, so the bar never
|
||||
disappears out from under your thumb. */}
|
||||
<button
|
||||
className="md:hidden p-2 shrink-0"
|
||||
onClick={() => setIsMobileMenuOpen((open) => !open)}
|
||||
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-bakery-700"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isMobileMenuOpen ? <path d="M6 18L18 6M6 6l12 12" /> : <path d="M4 6h16M4 12h16M4 18h16" />}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile navigation. Three things here are load-bearing:
|
||||
|
||||
It lives OUTSIDE <header>. 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. */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="md:hidden fixed inset-x-0 top-20 bottom-0 z-30 bg-bakery-50"
|
||||
>
|
||||
<nav className="container mx-auto px-4 flex flex-col">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -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<LogoProps> = ({ className = 'text-bakery-700', size = 'sm', linked = true }) => {
|
||||
const s = SIZES[size];
|
||||
const inner = (
|
||||
<>
|
||||
<Logo_R className={`${s.branch} shrink-0`} />
|
||||
<span className="flex flex-col items-center leading-none min-w-0">
|
||||
<span className={`font-lejour ${s.name}`} style={{ letterSpacing: '0.01em' }}>
|
||||
The Vine
|
||||
</span>
|
||||
<span className={`font-adbhashitha ${s.tag} uppercase mt-1.5 whitespace-nowrap`}>
|
||||
Coffeehouse + Bakery
|
||||
</span>
|
||||
</span>
|
||||
<Logo_L className={`${s.branch} shrink-0`} />
|
||||
</>
|
||||
);
|
||||
|
||||
const classes = `flex items-center ${s.gap} min-w-0 shrink ${className}`;
|
||||
|
||||
if (!linked) {
|
||||
return (
|
||||
<div className={`${classes} justify-center`} role="img" aria-label="The Vine Coffeehouse + Bakery">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to="/" className={classes} aria-label="The Vine Coffeehouse + Bakery, home">
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
interface ProductGalleryProps {
|
||||
images: string[];
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const Chevron = ({ direction }: { direction: 'left' | 'right' }) => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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<ProductGalleryProps> = ({ 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 (
|
||||
<div
|
||||
className="relative aspect-square bg-bakery-100"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onKeyDown={many ? (e) => {
|
||||
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) => (
|
||||
<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 motion-reduce:transition-none ${
|
||||
i === active ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-hidden={i === active ? undefined : true}
|
||||
/>
|
||||
))}
|
||||
|
||||
{many && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
aria-label={`Previous photo of ${alt}`}
|
||||
className={`${arrowClass} left-2`}
|
||||
>
|
||||
<Chevron direction="left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
aria-label={`Next photo of ${alt}`}
|
||||
className={`${arrowClass} right-2`}
|
||||
>
|
||||
<Chevron direction="right" />
|
||||
</button>
|
||||
|
||||
{/* Dots: how many photos there are, and which one you're on. */}
|
||||
<div className="absolute inset-x-0 bottom-3 flex justify-center gap-1.5">
|
||||
{images.map((src, i) => (
|
||||
<button
|
||||
key={src}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Show photo ${i + 1} of ${images.length} of ${alt}`}
|
||||
aria-current={i === active ? 'true' : undefined}
|
||||
className={`h-1.5 rounded-full shadow-xs transition-all motion-reduce:transition-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white ${
|
||||
i === active ? 'w-4 bg-white' : 'w-1.5 bg-white/60 hover:bg-white/80'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGallery;
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 <img> 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 <details>; 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
<groupId>net.thebennett.platform</groupId>
|
||||
<artifactId>platform-starter-data</artifactId>
|
||||
</dependency>
|
||||
<!-- The site is server-rendered: every public page is a Thymeleaf template in
|
||||
src/main/resources/templates, and the only JavaScript left on it is a 100-line file that
|
||||
gives the multi-photo product cards their arrows. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.thebennett.platform</groupId>
|
||||
<artifactId>platform-starter-contact</artifactId>
|
||||
@@ -117,10 +124,28 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<!-- SPA build inherited from platform-parent (node install + npm build + copy dist -> jar). -->
|
||||
<!-- Node install + npm install + `npm run build` (the admin screen) are inherited from
|
||||
platform-parent; the extra execution below compiles the server-rendered site's
|
||||
stylesheet. -->
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<!--
|
||||
Tailwind, run as a CLI over the Thymeleaf templates, straight into the build output.
|
||||
|
||||
Bound to process-classes rather than prepare-package (where the admin's Vite build
|
||||
sits) because `mvn spring-boot:run` stops at process-classes: bind it any later and
|
||||
a local run serves an unstyled site. It writes to target/classes/static/css, so the
|
||||
generated file is never mistaken for a source file.
|
||||
-->
|
||||
<execution>
|
||||
<id>npm-build-css</id>
|
||||
<phase>process-classes</phase>
|
||||
<goals><goal>npm</goal></goals>
|
||||
<configuration><arguments>run build:css</arguments></configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Map<String, Object>> submit(@RequestBody Submission body) {
|
||||
Enquiry enquiry = Enquiry.of(body.name(), body.email(), body.message());
|
||||
|
||||
// Validate first so junk never reaches the table, then record it BEFORE attempting delivery:
|
||||
// if the relay is down we still have the enquiry, flagged undelivered.
|
||||
contact.validate(enquiry);
|
||||
ContactEnquiry recorded = enquiries.save(
|
||||
new ContactEnquiry(enquiry.name(), enquiry.email(), enquiry.message()));
|
||||
|
||||
contact.submit(enquiry);
|
||||
|
||||
recorded.markDelivered();
|
||||
enquiries.save(recorded);
|
||||
enquiries.receive(body.name(), body.email(), body.message());
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@@ -55,7 +41,7 @@ public class ContactController {
|
||||
* The visitor sees this text, so it must stay the wording the service chose — never a stack trace
|
||||
* or a generic 500.
|
||||
*/
|
||||
@org.springframework.web.bind.annotation.ExceptionHandler(ContactException.class)
|
||||
@ExceptionHandler(ContactException.class)
|
||||
public ResponseEntity<Map<String, Object>> handle(ContactException ex) {
|
||||
HttpStatus status = ex.isClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.BAD_GATEWAY;
|
||||
return ResponseEntity.status(status).body(Map.of("error", ex.getMessage()));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.itsthevine.web.domain.ContactEnquiry;
|
||||
import com.itsthevine.web.domain.ContactEnquiryRepository;
|
||||
|
||||
import net.thebennett.platform.contact.ContactService;
|
||||
import net.thebennett.platform.contact.Enquiry;
|
||||
|
||||
/**
|
||||
* Taking an enquiry: check it, write it down, then try to deliver it.
|
||||
*
|
||||
* <p>The order matters and is the reason this is a service rather than three lines in a controller.
|
||||
* Validation first, so junk never reaches the table; the enquiry is recorded BEFORE delivery is
|
||||
* attempted, so a relay outage costs a notification rather than somebody's order; and
|
||||
* {@code delivered} is only set once the relay has actually taken it, which is what makes the
|
||||
* undelivered ones findable later.
|
||||
*
|
||||
* <p>Two things submit enquiries — the page's own form and {@code /api/contact} — and they must not
|
||||
* drift into two different orderings of those steps.
|
||||
*/
|
||||
@Service
|
||||
public class Enquiries {
|
||||
|
||||
private final ContactService contact;
|
||||
private final ContactEnquiryRepository enquiries;
|
||||
|
||||
public Enquiries(ContactService contact, ContactEnquiryRepository enquiries) {
|
||||
this.contact = contact;
|
||||
this.enquiries = enquiries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws net.thebennett.platform.contact.ContactException if it's not a usable enquiry, or the
|
||||
* relay refused it — the message is written for the visitor to read
|
||||
*/
|
||||
public void receive(String name, String email, String message) {
|
||||
Enquiry enquiry = Enquiry.of(name, email, message);
|
||||
|
||||
contact.validate(enquiry);
|
||||
ContactEnquiry recorded = enquiries.save(
|
||||
new ContactEnquiry(enquiry.name(), enquiry.email(), enquiry.message()));
|
||||
|
||||
contact.submit(enquiry);
|
||||
|
||||
recorded.markDelivered();
|
||||
enquiries.save(recorded);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Serves {@code index.html} with per-page title/description/OG tags filled in.
|
||||
*
|
||||
* <p>The site used to be server-rendered by Next, so every page came with its own metadata. A plain
|
||||
* SPA would hand crawlers and link-preview scrapers one generic shell for all four pages — a real
|
||||
* loss for a shop that people find by searching. Rendering just the {@code <head>} on the server keeps
|
||||
* that, without dragging SSR (and a Node runtime) into the one-jar model.
|
||||
*
|
||||
* <p>Only the four real routes are listed. Anything else falls through to the platform's SPA
|
||||
* fallback, which is what we want for 404s — no invented metadata for URLs that don't exist.
|
||||
*/
|
||||
@Controller
|
||||
public class PageMetaController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PageMetaController.class);
|
||||
|
||||
private static final String NAME = "The Vine Coffeehouse + Bakery";
|
||||
private static final String HOME_DESCRIPTION =
|
||||
"A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, "
|
||||
+ "custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.";
|
||||
|
||||
private record PageMeta(String title, String description) {}
|
||||
|
||||
private static final Map<String, PageMeta> PAGES = new LinkedHashMap<>(Map.of(
|
||||
"/", new PageMeta(NAME, HOME_DESCRIPTION),
|
||||
"/products", new PageMeta("Our products · " + NAME,
|
||||
"Cinnamon rolls, caramel rolls, scones, cookie bars, macarons, brownies, pies, and "
|
||||
+ "made-to-order cakes and decorated cookies from The Vine in Princeville, Illinois."),
|
||||
"/history", new PageMeta("Our story · " + NAME,
|
||||
"Morissa Bennett opened The Vine in 2024 at 215 E Main Street in downtown Princeville, "
|
||||
+ "Illinois. We bake in our own kitchen on Main Street."),
|
||||
"/contact", new PageMeta("Contact us · " + NAME,
|
||||
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
|
||||
+ "Illinois. Call (309) 701-0660 or send us a message.")));
|
||||
|
||||
private static final Pattern TITLE = Pattern.compile("<title>.*?</title>", Pattern.DOTALL);
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final String baseUrl;
|
||||
|
||||
/** Cached because the file never changes at runtime — it's baked into the jar. */
|
||||
private volatile String template;
|
||||
|
||||
public PageMetaController(ResourceLoader resourceLoader,
|
||||
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
|
||||
this.resourceLoader = resourceLoader;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
@GetMapping(value = {"/", "/products", "/history", "/contact"}, produces = MediaType.TEXT_HTML_VALUE)
|
||||
@ResponseBody
|
||||
public String page(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
PageMeta meta = PAGES.getOrDefault(path, PAGES.get("/"));
|
||||
String html = template();
|
||||
if (html == null) {
|
||||
// No built SPA (backend-only build). Nothing to decorate.
|
||||
return "<!doctype html><title>" + escape(meta.title()) + "</title>";
|
||||
}
|
||||
return render(html, meta, path);
|
||||
}
|
||||
|
||||
private String render(String html, PageMeta meta, String path) {
|
||||
String out = TITLE.matcher(html).replaceFirst(
|
||||
Matcher.quoteReplacement("<title>" + escape(meta.title()) + "</title>"));
|
||||
out = setMeta(out, "name", "description", meta.description());
|
||||
out = setMeta(out, "property", "og:title", meta.title());
|
||||
out = setMeta(out, "property", "og:description", meta.description());
|
||||
out = setMeta(out, "property", "og:url", baseUrl + ("/".equals(path) ? "" : path));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the {@code content} of an existing meta tag. Deliberately does not add missing tags —
|
||||
* index.html carries the full set, so a miss here means the template changed and should be fixed
|
||||
* there rather than papered over with a duplicate tag.
|
||||
*/
|
||||
private static String setMeta(String html, String keyAttr, String key, String value) {
|
||||
Pattern p = Pattern.compile(
|
||||
"(<meta\\s+" + keyAttr + "=\"" + Pattern.quote(key) + "\"\\s+content=\")[^\"]*(\")");
|
||||
Matcher m = p.matcher(html);
|
||||
if (!m.find()) {
|
||||
log.warn("index.html has no <meta {}=\"{}\"> to fill in", keyAttr, key);
|
||||
return html;
|
||||
}
|
||||
// Splice by index rather than replaceFirst: replacement strings give $ and \ special meaning,
|
||||
// and these values are prose.
|
||||
return new StringBuilder(html)
|
||||
.replace(m.start(), m.end(), m.group(1) + escape(value) + m.group(2))
|
||||
.toString();
|
||||
}
|
||||
|
||||
private String template() {
|
||||
String cached = template;
|
||||
if (cached == null) {
|
||||
synchronized (this) {
|
||||
if (template == null) {
|
||||
template = load();
|
||||
}
|
||||
cached = template;
|
||||
}
|
||||
}
|
||||
return cached.isEmpty() ? null : cached;
|
||||
}
|
||||
|
||||
private String load() {
|
||||
Resource resource = resourceLoader.getResource("classpath:/static/index.html");
|
||||
if (!resource.exists()) {
|
||||
log.warn("no classpath:/static/index.html — serving pages without metadata");
|
||||
return "";
|
||||
}
|
||||
try (var in = resource.getInputStream()) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
log.error("could not read index.html", e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Escapes for both element text and double-quoted attribute values. */
|
||||
private static String escape(String s) {
|
||||
return s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -32,16 +28,12 @@ public class ProductCatalog {
|
||||
|
||||
private final ProductRepository products;
|
||||
private final CategoryRepository categories;
|
||||
private final String assetBaseUrl;
|
||||
private final SitePhotos photos;
|
||||
|
||||
public ProductCatalog(ProductRepository products,
|
||||
CategoryRepository categories,
|
||||
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) {
|
||||
public ProductCatalog(ProductRepository products, CategoryRepository categories, SitePhotos photos) {
|
||||
this.products = products;
|
||||
this.categories = categories;
|
||||
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
|
||||
// up in every image URL on the page.
|
||||
this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", "");
|
||||
this.photos = photos;
|
||||
}
|
||||
|
||||
public record ProductView(Long id, String name, String category, List<String> images) {}
|
||||
@@ -94,18 +86,6 @@ public class ProductCatalog {
|
||||
|
||||
private ProductView toView(Product p) {
|
||||
return new ProductView(p.getId(), p.getName(), p.getCategory(),
|
||||
p.getImageKeys().stream().map(this::imageUrl).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Some photo filenames contain spaces ("Cinnamon Rolls.webp"), and a raw space in a URL doesn't
|
||||
* fetch — so each path segment is encoded. {@code URLEncoder} is form-encoding, which differs
|
||||
* from path-encoding in exactly one way that matters here: it turns a space into '+'.
|
||||
*/
|
||||
private String imageUrl(String key) {
|
||||
String encoded = Arrays.stream(key.replaceAll("^/+", "").split("/"))
|
||||
.map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"))
|
||||
.collect(Collectors.joining("/"));
|
||||
return assetBaseUrl + "/images/" + encoded;
|
||||
p.getImageKeys().stream().map(photos::of).toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import net.thebennett.platform.contact.ContactException;
|
||||
|
||||
/**
|
||||
* The site: every page a customer sees, rendered on the server with Thymeleaf.
|
||||
*
|
||||
* <p>This replaced a React SPA and, with it, {@code PageMetaController} — a class whose whole job was
|
||||
* to splice per-page {@code <title>} and OG tags into the SPA's one shell with regular expressions,
|
||||
* because crawlers and link-preview scrapers got nothing useful otherwise. A server-rendered page has
|
||||
* a head of its own, so that machinery is gone rather than ported.
|
||||
*
|
||||
* <p>Every page states its own title and description here, in Java, next to the route that serves it.
|
||||
* The templates only lay them out.
|
||||
*/
|
||||
@Controller
|
||||
public class SiteController {
|
||||
|
||||
private static final String NAME = "The Vine Coffeehouse + Bakery";
|
||||
|
||||
private final ProductCatalog catalog;
|
||||
private final CateringMenu catering;
|
||||
private final Enquiries enquiries;
|
||||
private final SitePhotos photos;
|
||||
|
||||
public SiteController(ProductCatalog catalog, CateringMenu catering, Enquiries enquiries, SitePhotos photos) {
|
||||
this.catalog = catalog;
|
||||
this.catering = catering;
|
||||
this.enquiries = enquiries;
|
||||
this.photos = photos;
|
||||
}
|
||||
|
||||
@GetMapping("/")
|
||||
public String home(Model model) {
|
||||
meta(model, "/", NAME,
|
||||
"A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake "
|
||||
+ "pastries, custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, "
|
||||
+ "and coffee.");
|
||||
// The hero photo is the largest thing on the page and the first thing you see; telling the
|
||||
// browser about it in the head starts it a round trip sooner.
|
||||
model.addAttribute("preload", photos.of("gallery/Outside.webp"));
|
||||
return "home";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param category a stored category, or absent/"All" for the whole catalogue. A query parameter
|
||||
* rather than a click handler: the filtered page is now a real URL you can send
|
||||
* someone, and the filter is applied by the same code that answers /api/products.
|
||||
*/
|
||||
@GetMapping("/products")
|
||||
public String products(@RequestParam(required = false) String category, Model model) {
|
||||
String selected = category == null || category.isBlank() ? ProductCatalog.ALL : category;
|
||||
meta(model, "/products", "Our products · " + NAME,
|
||||
"Cinnamon rolls, caramel rolls, scones, cookie bars, macarons, brownies, pies, and "
|
||||
+ "made-to-order cakes and decorated cookies from The Vine in Princeville, Illinois.");
|
||||
model.addAttribute("categories", catalog.categories());
|
||||
model.addAttribute("selected", selected);
|
||||
model.addAttribute("products", catalog.list(selected));
|
||||
return "products";
|
||||
}
|
||||
|
||||
@GetMapping("/catering")
|
||||
public String catering(Model model) {
|
||||
meta(model, "/catering", "Goodie boxes & catering · " + NAME,
|
||||
"Goodie boxes for the office, party packages, and wedding cakes and desserts from The "
|
||||
+ "Vine in Princeville, Illinois — what each size includes, and what it costs.");
|
||||
model.addAttribute("menu", catering.menu());
|
||||
return "catering";
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
public String history(Model model) {
|
||||
meta(model, "/history", "Our story · " + NAME,
|
||||
"Morissa Bennett opened The Vine in 2024 at 215 E Main Street in downtown Princeville, "
|
||||
+ "Illinois. We bake in our own kitchen on Main Street.");
|
||||
return "history";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param about which catering table they came from, if they arrived by one of that page's buttons.
|
||||
* The message box starts with the question already half-asked — the alternative is a
|
||||
* blank box and an enquiry that says "how much?" with no way to tell what about.
|
||||
*/
|
||||
@GetMapping("/contact")
|
||||
public String contact(@RequestParam(required = false) String about, Model model) {
|
||||
contactMeta(model);
|
||||
// Matched against the real table names rather than echoed: this text ends up in a box on the
|
||||
// page, and a query parameter is whatever a link says it is. Thymeleaf would escape it, but a
|
||||
// link that puts words of someone else's choosing in front of a customer is still not a link we
|
||||
// want to work.
|
||||
catering.menu().packages().stream()
|
||||
.map(CateringMenu.PackageView::name)
|
||||
.filter(name -> name.equalsIgnoreCase(about))
|
||||
.findFirst()
|
||||
.ifPresent(name -> model.addAttribute("message",
|
||||
"I'd like to ask about " + name.toLowerCase() + " catering — "));
|
||||
return "contact";
|
||||
}
|
||||
|
||||
/**
|
||||
* The form posts here and gets a page back — no JavaScript involved in sending an enquiry.
|
||||
*
|
||||
* <p>It renders rather than redirects on both outcomes, deliberately. A failed send has to come
|
||||
* back with what the visitor typed still in the boxes: they wrote it once, and the failure is ours
|
||||
* (a refused relay), not theirs. On success the fields are cleared and the message replaces them.
|
||||
*/
|
||||
@PostMapping("/contact")
|
||||
public String submit(@RequestParam String name,
|
||||
@RequestParam String email,
|
||||
@RequestParam String message,
|
||||
Model model) {
|
||||
contactMeta(model);
|
||||
try {
|
||||
enquiries.receive(name, email, message);
|
||||
model.addAttribute("sent", true);
|
||||
} catch (ContactException e) {
|
||||
// The service wrote this sentence for the visitor to read; don't replace it with a status
|
||||
// code or a stack trace.
|
||||
model.addAttribute("error", e.getMessage());
|
||||
model.addAttribute("name", name);
|
||||
model.addAttribute("email", email);
|
||||
model.addAttribute("message", message);
|
||||
}
|
||||
return "contact";
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin is still a React screen, and this is the one route that serves it.
|
||||
*
|
||||
* <p>The platform's SPA fallback used to do this for every extension-less path, which is why it's
|
||||
* switched off in application.yaml: with the site server-rendered, forwarding an unknown URL to a
|
||||
* JavaScript shell would answer a typo with a blank page and a 200 instead of the site's own 404.
|
||||
*/
|
||||
@GetMapping("/admin")
|
||||
public String admin() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
private void contactMeta(Model model) {
|
||||
meta(model, "/contact", "Contact us · " + NAME,
|
||||
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
|
||||
+ "Illinois. Call (309) 701-0660 or send us a message.");
|
||||
}
|
||||
|
||||
/** @param path the route, so the head can build an absolute og:url for the scrapers */
|
||||
private static void meta(Model model, String path, String title, String description) {
|
||||
model.addAttribute("path", path);
|
||||
model.addAttribute("title", title);
|
||||
model.addAttribute("description", description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
/**
|
||||
* The handful of things every page's chrome needs, added to the model once instead of by each handler.
|
||||
*
|
||||
* <p>Not scoped to {@link SiteController}: the error pages wear the same chrome, and Boot renders those
|
||||
* through its own controller. The JSON controllers get these attributes too and ignore them, which
|
||||
* costs nothing — a {@code @ResponseBody} method never looks at the model.
|
||||
*/
|
||||
@ControllerAdvice
|
||||
public class SiteModel {
|
||||
|
||||
/** The shop is in Princeville, Illinois; the container runs on UTC, which turns over first. */
|
||||
private static final ZoneId SHOP_TIME = ZoneId.of("America/Chicago");
|
||||
|
||||
private final SitePhotos photos;
|
||||
private final String baseUrl;
|
||||
private final String build;
|
||||
|
||||
public SiteModel(SitePhotos photos,
|
||||
@Value("${site.base-url:https://itsthevine.com}") String baseUrl,
|
||||
@Value("${site.build:dev}") String build) {
|
||||
this.photos = photos;
|
||||
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||
this.build = build;
|
||||
}
|
||||
|
||||
/** Lets a template ask for a photo by key: {@code ${photos.of('gallery/Outside.webp')}}. */
|
||||
@ModelAttribute("photos")
|
||||
public SitePhotos photos() {
|
||||
return photos;
|
||||
}
|
||||
|
||||
/** Absolute URLs for og:url, which only means anything to a scraper if it's absolute. */
|
||||
@ModelAttribute("baseUrl")
|
||||
public String baseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
@ModelAttribute("assetOrigin")
|
||||
public String assetOrigin() {
|
||||
return photos.origin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hung on the stylesheet URL as {@code ?v=…}.
|
||||
*
|
||||
* <p>The SPA's bundles had content hashes in their filenames; one hand-written stylesheet does not,
|
||||
* so without this a visitor keeps whatever CSS they cached before the deploy — new markup, old
|
||||
* rules. The deployment passes the commit sha; a dev run says "dev".
|
||||
*/
|
||||
@ModelAttribute("build")
|
||||
public String build() {
|
||||
return build;
|
||||
}
|
||||
|
||||
/** The footer's copyright year. */
|
||||
@ModelAttribute("year")
|
||||
public int year() {
|
||||
return ZonedDateTime.now(SHOP_TIME).getYear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Where a photo lives: {@code of("gallery/Outside.webp")} → the absolute bucket URL.
|
||||
*
|
||||
* <p>Photos are in a public MinIO bucket rather than the image — 50 MB of JPEGs has no business inside
|
||||
* a container we redeploy on every commit — so the bucket's address is deployment configuration and the
|
||||
* absolute URL is built here rather than stored on anything. Both the catalogue and the templates use
|
||||
* this, so an editor can never arrange a photo that resolves differently once it's live.
|
||||
*/
|
||||
@Service
|
||||
public class SitePhotos {
|
||||
|
||||
private final String baseUrl;
|
||||
|
||||
public SitePhotos(@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String baseUrl) {
|
||||
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
|
||||
// up in every image URL on the page.
|
||||
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Some photo filenames contain spaces ("Cinnamon Rolls.webp"), and a raw space in a URL doesn't
|
||||
* fetch — so each path segment is encoded. {@code URLEncoder} is form-encoding, which differs from
|
||||
* path-encoding in exactly one way that matters here: it turns a space into '+'.
|
||||
*/
|
||||
public String of(String key) {
|
||||
String encoded = Arrays.stream(key.replaceAll("^/+", "").split("/"))
|
||||
.map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"))
|
||||
.collect(Collectors.joining("/"));
|
||||
return baseUrl + "/images/" + encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just the scheme and host, for the {@code preconnect} in the page head: opening that connection
|
||||
* during the head saves the hero image a round trip.
|
||||
*/
|
||||
public String origin() {
|
||||
URI uri = URI.create(baseUrl);
|
||||
return uri.getScheme() + "://" + uri.getAuthority();
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,11 @@ spring:
|
||||
platform:
|
||||
web:
|
||||
spa:
|
||||
enabled: true
|
||||
# OFF. The platform's fallback forwards every extension-less path to /index.html so a React SPA can
|
||||
# own routing; this site is server-rendered, and /index.html is now only the admin shell. Left on,
|
||||
# a mistyped URL would answer with a blank JavaScript page and a 200 instead of the site's own 404.
|
||||
# SiteController maps /admin to the shell explicitly — that one route is all the SPA is for.
|
||||
enabled: false
|
||||
data:
|
||||
auditing:
|
||||
enabled: true
|
||||
@@ -69,6 +73,10 @@ platform:
|
||||
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
||||
site:
|
||||
base-url: ${SITE_BASE_URL:https://itsthevine.com}
|
||||
# Hung on the stylesheet URL as ?v=… The SPA's bundles had content hashes in their filenames; one
|
||||
# hand-written stylesheet does not, so without this a returning visitor keeps the CSS they cached
|
||||
# before the deploy. The CI build already passes the commit sha to the image.
|
||||
build: ${GIT_SHA:dev}
|
||||
assets:
|
||||
# Where uploaded photos land. The key stored on a product is bucket-relative and excludes the
|
||||
# prefix, because ProductCatalog re-adds `/images/` when it builds the public URL.
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Arrows and dots for the product cards that have more than one photo.
|
||||
*
|
||||
* This is the only JavaScript the public site loads, and the site works without it: each gallery is a
|
||||
* scroll-snap strip, so the photos are already swipeable on a phone and scrollable with a trackpad. What
|
||||
* this adds is the two chevrons and the row of dots the site has always had — controls that need script
|
||||
* to do anything, which is why they are created here rather than rendered in the template and left dead
|
||||
* for anyone whose JavaScript did not load.
|
||||
*
|
||||
* Tailwind finds the class names below because src/main/tailwind/site.css lists this directory as a
|
||||
* @source. Write them out in full — a class assembled from pieces at runtime would not be in the CSS.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ARROW =
|
||||
'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';
|
||||
|
||||
function chevron(direction) {
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'h-5 w-5');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6');
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function enhance(strip) {
|
||||
var photos = strip.querySelectorAll('img');
|
||||
if (photos.length < 2) return;
|
||||
|
||||
var frame = strip.parentElement;
|
||||
var name = photos[0].getAttribute('alt') || 'this item';
|
||||
|
||||
// Which photo is showing: whichever one's left edge is nearest the strip's scroll position. Read
|
||||
// from the scroll position rather than tracked in a variable, so a swipe and an arrow press can
|
||||
// never disagree about where we are.
|
||||
function current() {
|
||||
return Math.round(strip.scrollLeft / strip.clientWidth);
|
||||
}
|
||||
|
||||
function show(index) {
|
||||
var target = Math.max(0, Math.min(photos.length - 1, index));
|
||||
strip.scrollTo({ left: target * strip.clientWidth, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function arrow(direction, label, position) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = ARROW + ' ' + position;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.appendChild(chevron(direction));
|
||||
button.addEventListener('click', function () {
|
||||
// Wrapping, as the old carousel did: past the last photo you land back on the first.
|
||||
var next = current() + (direction === 'left' ? -1 : 1);
|
||||
if (next < 0) next = photos.length - 1;
|
||||
if (next > photos.length - 1) next = 0;
|
||||
show(next);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
frame.appendChild(arrow('left', 'Previous photo of ' + name, 'left-2'));
|
||||
frame.appendChild(arrow('right', 'Next photo of ' + name, 'right-2'));
|
||||
|
||||
var dots = document.createElement('div');
|
||||
dots.className = 'absolute inset-x-0 bottom-3 flex justify-center gap-1.5';
|
||||
var buttons = [];
|
||||
photos.forEach(function (_photo, index) {
|
||||
var dot = document.createElement('button');
|
||||
dot.type = 'button';
|
||||
dot.setAttribute(
|
||||
'aria-label',
|
||||
'Show photo ' + (index + 1) + ' of ' + photos.length + ' of ' + name,
|
||||
);
|
||||
dot.addEventListener('click', function () {
|
||||
show(index);
|
||||
});
|
||||
buttons.push(dot);
|
||||
dots.appendChild(dot);
|
||||
});
|
||||
frame.appendChild(dots);
|
||||
|
||||
function paint() {
|
||||
var active = current();
|
||||
buttons.forEach(function (dot, index) {
|
||||
dot.className =
|
||||
'h-1.5 rounded-full shadow-xs transition-all motion-reduce:transition-none focus:outline-none ' +
|
||||
'focus-visible:ring-2 focus-visible:ring-white ' +
|
||||
(index === active ? 'w-4 bg-white' : 'w-1.5 bg-white/60 hover:bg-white/80');
|
||||
if (index === active) {
|
||||
dot.setAttribute('aria-current', 'true');
|
||||
} else {
|
||||
dot.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard: the strip is focusable, so left/right work once it has focus.
|
||||
strip.tabIndex = 0;
|
||||
strip.setAttribute('role', 'group');
|
||||
strip.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
show(current() - 1);
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
show(current() + 1);
|
||||
}
|
||||
});
|
||||
|
||||
var scheduled = false;
|
||||
strip.addEventListener(
|
||||
'scroll',
|
||||
function () {
|
||||
// A smooth scroll fires this dozens of times; repaint once per frame.
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(function () {
|
||||
scheduled = false;
|
||||
paint();
|
||||
});
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
paint();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.gallery').forEach(enhance);
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Goodie boxes & catering</h1>
|
||||
<p class="mt-4 text-bakery-800 max-w-2xl mx-auto leading-relaxed">
|
||||
Boxes for the office, packages for a party, and cakes and desserts for a wedding. Every one of
|
||||
these is a starting point — tell us what you have in mind and we will work from it.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16 md:pb-24">
|
||||
<div class="max-w-4xl mx-auto space-y-10 md:space-y-12">
|
||||
|
||||
<!--/*
|
||||
One table per package. Two renderings of the same data, and only one is ever visible:
|
||||
|
||||
- A real <table> from `md` up. These are prices in columns; a table is what that is, and a
|
||||
screen reader announces the size and the item together because of the row and column headers.
|
||||
- Stacked cards below `md`. A four-column price table on a phone is either an illegible squeeze
|
||||
or a sideways scroll, and this page is mostly read on phones.
|
||||
|
||||
Both come from the same model, so they cannot drift.
|
||||
*/-->
|
||||
<section th:each="table : ${menu.packages}"
|
||||
class="bg-white rounded-3xl shadow-xs overflow-hidden">
|
||||
<div class="px-6 pt-6 md:px-8 md:pt-8">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900" th:text="${table.name}">Office</h2>
|
||||
<p th:if="${table.blurb}" class="mt-2 text-bakery-700" th:text="${table.blurb}">Blurb</p>
|
||||
</div>
|
||||
|
||||
<!--/* Wide: the table. */-->
|
||||
<div class="hidden md:block px-8 pt-6">
|
||||
<table class="w-full border-collapse text-left">
|
||||
<caption class="sr-only" th:text="|${table.name} — what each size includes and what it costs|">Sizes</caption>
|
||||
<thead>
|
||||
<tr class="border-b border-bakery-200">
|
||||
<th scope="col" class="py-3 pr-4 text-xs uppercase tracking-[0.15em] text-bakery-600 font-medium align-bottom">
|
||||
What you get
|
||||
</th>
|
||||
<th th:each="tier : ${table.tiers}" scope="col" class="py-3 px-4 align-bottom">
|
||||
<span class="block font-adbhashitha text-lg text-bakery-900" th:text="${tier.label}">Small</span>
|
||||
<span th:if="${tier.price}" class="block text-bakery-700" th:text="${tier.price}">$24</span>
|
||||
<!--/* No price means "ask us" — say so rather than leaving a hole in the column. */-->
|
||||
<span th:unless="${tier.price}" class="block text-bakery-600 text-sm">Ask us</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${table.rows}" class="border-b border-bakery-100 last:border-0 align-top">
|
||||
<th scope="row" class="py-4 pr-4 font-medium text-bakery-900" th:text="${row.label}">Mini muffins</th>
|
||||
<td th:each="value : ${row.values}" class="py-4 px-4 text-bakery-800">
|
||||
<span th:if="${!#strings.isEmpty(value)}" th:text="${value}">12 items</span>
|
||||
<!--/* A cell the bakery hasn't filled in. An em dash reads as "nothing here"; an empty
|
||||
cell reads as a broken page. */-->
|
||||
<span th:if="${#strings.isEmpty(value)}" class="text-bakery-400" aria-hidden="true">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--/* Narrow: one card per size. */-->
|
||||
<div class="md:hidden px-6 pt-6 space-y-4">
|
||||
<div th:each="tier, t : ${table.tiers}" class="rounded-2xl border border-bakery-200 p-4">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h3 class="font-adbhashitha text-lg text-bakery-900" th:text="${tier.label}">Small</h3>
|
||||
<span th:if="${tier.price}" class="text-bakery-700 whitespace-nowrap" th:text="${tier.price}">$24</span>
|
||||
<span th:unless="${tier.price}" class="text-bakery-600 text-sm whitespace-nowrap">Ask us</span>
|
||||
</div>
|
||||
<!--/* The cell for this size on each line — t.index picks this column out of every row, which
|
||||
is exactly the alignment the aggregate guarantees. Lines with nothing in this column are
|
||||
still listed: they are part of what's in the box, and the bakery just hasn't said how
|
||||
many yet. */-->
|
||||
<dl class="mt-3 space-y-2">
|
||||
<div th:each="row : ${table.rows}" class="flex justify-between gap-4 text-sm">
|
||||
<dt class="text-bakery-900" th:text="${row.label}">Mini muffins</dt>
|
||||
<dd class="text-bakery-800 text-right">
|
||||
<span th:if="${!#strings.isEmpty(row.values[t.index])}" th:text="${row.values[t.index]}">12 items</span>
|
||||
<span th:if="${#strings.isEmpty(row.values[t.index])}" class="text-bakery-400">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:if="${!#lists.isEmpty(table.notes)}" class="px-6 md:px-8 pt-6">
|
||||
<ul class="space-y-2 text-sm text-bakery-700">
|
||||
<li th:each="note : ${table.notes}" th:text="${note}">Minimum of 6 items per baked good.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-6 pt-6 md:px-8 md:pb-8">
|
||||
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
|
||||
class="inline-block bg-bakery-600 hover:bg-bakery-700 text-white px-6 py-3 rounded-full font-medium tracking-wide transition">
|
||||
Ask about <span th:text="${#strings.toLowerCase(table.name)}">office</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--/* The terms that apply whichever table you were reading. */-->
|
||||
<section th:if="${!#lists.isEmpty(menu.notes)}" class="rounded-3xl border border-bakery-200 p-6 md:p-8">
|
||||
<h2 class="font-adbhashitha text-xl text-bakery-900">Before you order</h2>
|
||||
<ul class="mt-4 space-y-2 text-bakery-800">
|
||||
<li th:each="note : ${menu.notes}" th:text="${note}">Prices may change.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!--/* Only shows if every table has been emptied out in the admin. Better than a bare heading. */-->
|
||||
<p th:if="${#lists.isEmpty(menu.packages)}" class="text-center text-bakery-800">
|
||||
Our catering list is being updated. Call us on
|
||||
<a href="tel:+13097010660" class="underline underline-offset-4">(309) 701-0660</a> and we will
|
||||
talk it through.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Contact us</h1>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16 md:pb-24">
|
||||
<div class="max-w-2xl mx-auto bg-white p-8 md:p-10 rounded-3xl shadow-xs">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-2">Get in touch</h2>
|
||||
<p class="text-bakery-700 mb-8">
|
||||
Or call us: <a href="tel:+13097010660" class="underline underline-offset-4 hover:text-bakery-600">(309) 701-0660</a>
|
||||
</p>
|
||||
|
||||
<!--/*
|
||||
A plain form post. The enquiry is validated, written down and mailed by the server, which then
|
||||
renders this page again with the outcome — no fetch, no JSON, and nothing to go wrong between
|
||||
pressing the button and the enquiry being recorded.
|
||||
|
||||
Spring Security's CSRF token is added to any th:action form automatically, which is what keeps
|
||||
this working on a deployment that has an identity provider configured (there, an unaccompanied
|
||||
POST is rejected).
|
||||
*/-->
|
||||
<!--/* th:unless, not th:if="${!sent}": the attribute is absent on a GET, and SpEL cannot negate
|
||||
a null. */-->
|
||||
<form th:action="@{/contact}" method="post" th:unless="${sent}">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="name">Name</label>
|
||||
<input type="text" id="name" name="name" th:value="${name}" required
|
||||
class="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">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="email">Email</label>
|
||||
<input type="email" id="email" name="email" th:value="${email}" required
|
||||
class="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">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="5" required th:text="${message}"
|
||||
class="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"></textarea>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors">
|
||||
Send message
|
||||
</button>
|
||||
|
||||
<!--/* Never claim a success we did not get: say what happened and give them the phone number. */-->
|
||||
<p th:if="${error}" role="alert" class="text-center text-red-700">
|
||||
<span th:text="${error}">Could not send the message.</span>
|
||||
Please call us on
|
||||
<a href="tel:+13097010660" class="underline underline-offset-4">(309) 701-0660</a>.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p th:if="${sent}" role="status" class="text-bakery-700">
|
||||
Thanks. Your message is on its way, and we will get back to you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<!--/*
|
||||
Anything that isn't a 404: the site's own page rather than Boot's white error screen, which shows a
|
||||
stack-trace-shaped block of text to whoever happens to be standing in the shop.
|
||||
|
||||
It deliberately says nothing about what broke. The person reading it can't act on that; the phone
|
||||
number is the thing they can use, and the log has the details.
|
||||
*/-->
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/page :: page(
|
||||
'Something went wrong · The Vine Coffeehouse + Bakery',
|
||||
'Something went wrong on our side. Call The Vine on (309) 701-0660.',
|
||||
'/', ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="bg-bakery-50">
|
||||
<div class="container mx-auto px-4 py-24 md:py-32 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">Something went wrong</h1>
|
||||
<p class="text-bakery-800 mb-10">
|
||||
That is our fault, not yours. Try again in a moment, or call us on
|
||||
<a href="tel:+13097010660" class="underline underline-offset-4">(309) 701-0660</a>.
|
||||
</p>
|
||||
<a href="/"
|
||||
class="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
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<!--/*
|
||||
The site's own 404, in the site's own chrome.
|
||||
|
||||
Boot picks this up by name for any 404, which is why the SPA fallback is switched off in
|
||||
application.yaml: while it was on, every mistyped URL was answered with the JavaScript shell and a
|
||||
200 — and now that the shell only carries the admin, that would be a blank page.
|
||||
|
||||
Boot renders it through its own error controller, so SiteController's model isn't here — which is why
|
||||
the layout takes the title and description as parameters and this page passes its own.
|
||||
*/-->
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/page :: page(
|
||||
'Page not found · The Vine Coffeehouse + Bakery',
|
||||
'That page is not here. The menu, our catering list, our story and how to reach us are.',
|
||||
'/', ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="bg-bakery-50">
|
||||
<div class="container mx-auto px-4 py-24 md:py-32 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">
|
||||
We could not find that page
|
||||
</h1>
|
||||
<p class="text-bakery-800 mb-10">
|
||||
It may have moved. The menu, our story, and how to reach us are all still here.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a href="/"
|
||||
class="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
|
||||
</a>
|
||||
<a href="/products"
|
||||
class="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
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<footer th:fragment="footer" class="bg-bakery-900 text-bakery-100">
|
||||
<div class="container mx-auto px-4 py-14">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||
<div class="col-span-1 md:col-span-2 flex items-start">
|
||||
<div th:replace="~{fragments/lockup :: small('text-bakery-50')}"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/products" class="hover:text-white transition">Our Products</a></li>
|
||||
<li><a href="/catering" class="hover:text-white transition">Goodie Boxes & Catering</a></li>
|
||||
<li><a href="/history" class="hover:text-white transition">Our Story</a></li>
|
||||
<li><a href="/contact" class="hover:text-white transition">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Visit</h3>
|
||||
<address class="not-italic space-y-2">
|
||||
<p>215 E Main Street<br>Princeville, IL 61559</p>
|
||||
<p><a href="tel:+13097010660" class="hover:text-white transition">(309) 701-0660</a></p>
|
||||
<p class="break-words">
|
||||
<a href="mailto:[email protected]" class="hover:text-white transition">[email protected]</a>
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center text-sm text-bakery-300">
|
||||
<p>© <span th:text="${year}">2026</span> The Vine Coffeehouse + Bakery</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!--/*
|
||||
The page head. `title`, `description`, `path` and (on the homepage) `preload` come from the model —
|
||||
see SiteController, where each route states its own.
|
||||
|
||||
This is what the SPA could not do. PageMetaController used to rewrite the shell's <title> and four
|
||||
meta tags with regular expressions on the way out, and its test read the real index.html so that
|
||||
reformatting that file failed the build rather than silently breaking the rewriting. A page that is
|
||||
rendered on the server just writes its own head, so all of that is gone.
|
||||
*/-->
|
||||
<head th:fragment="head(title, description, path)">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png">
|
||||
<link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png">
|
||||
|
||||
<!--/* Open the connection to the photo bucket while the head is still parsing. */-->
|
||||
<link rel="preconnect" th:href="${assetOrigin}" crossorigin>
|
||||
<!--/* The wordmark is set in Raleway; without preloading it arrives late and the logo visibly reflows. */-->
|
||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/raleway-latin.woff2" crossorigin>
|
||||
<link th:if="${preload}" rel="preload" as="image" th:href="${preload}" fetchpriority="high">
|
||||
|
||||
<title th:text="${title}">The Vine Coffeehouse + Bakery</title>
|
||||
<meta name="description" th:content="${description}">
|
||||
<meta property="og:title" th:content="${title}">
|
||||
<meta property="og:description" th:content="${description}">
|
||||
<meta property="og:url" th:content="${baseUrl + (path == '/' ? '' : path)}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
|
||||
|
||||
<!--/* ?v= is the commit sha in a deployment. One hand-written stylesheet has no content hash in its
|
||||
name, so without it a returning visitor keeps the CSS they cached before the deploy. */-->
|
||||
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<!--/*
|
||||
The sticky bar, and the mobile menu.
|
||||
|
||||
The menu is a <details> element. That is not a trick to avoid writing JavaScript for its own sake —
|
||||
the React version needed an effect to close the panel on navigation, another to close it on Escape,
|
||||
another to stop the page behind it scrolling, and a third to unmount it (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 <details> closes on Escape by itself, and navigation is a new document, so it cannot survive
|
||||
it. Nothing to remember, nothing to clean up.
|
||||
|
||||
The panel is `absolute` and starts below the bar (`top-full`), so the logo and the toggle stay put and
|
||||
the panel needs no second copy of either. It must not be `fixed`: the header carries `backdrop-blur`,
|
||||
and a backdrop-filter makes an element a containing block for fixed descendants — a fixed panel in
|
||||
here would resolve against the 80px header box and get clipped to a sliver.
|
||||
*/-->
|
||||
<header th:fragment="header" class="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between gap-2 h-20 md:h-24">
|
||||
<div th:replace="~{fragments/lockup :: small('text-bakery-700')}"></div>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-8">
|
||||
<th:block th:replace="~{:: links('text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition')}"></th:block>
|
||||
</nav>
|
||||
|
||||
<details class="group md:hidden shrink-0">
|
||||
<summary class="p-2 cursor-pointer list-none" aria-label="Menu">
|
||||
<svg class="h-6 w-6 text-bakery-700 group-open:hidden" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg class="h-6 w-6 text-bakery-700 hidden group-open:block" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="absolute inset-x-0 top-full z-30 h-[calc(100dvh-5rem)] bg-bakery-50">
|
||||
<nav class="container mx-auto px-4 flex flex-col">
|
||||
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100')}"></th:block>
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--/* One list of links, worn two ways — so the phone and the desktop can't end up offering different
|
||||
pages. */-->
|
||||
<th:block th:fragment="links(itemClass)">
|
||||
<a href="/products" th:class="${itemClass}">Our Products</a>
|
||||
<a href="/catering" th:class="${itemClass}">Catering</a>
|
||||
<a href="/history" th:class="${itemClass}">Our Story</a>
|
||||
<a href="/contact" th:class="${itemClass}">Contact</a>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<!--/*
|
||||
The wordmark: branch · "The Vine" over "Coffeehouse + Bakery" · branch.
|
||||
|
||||
The two branch marks are masks over currentColor (see .mark in tokens.css), so the whole lockup takes
|
||||
its colour from the `tint` class the caller passes — sage in the header, cream on the hero and in the
|
||||
footer. Branch heights track the two-line wordmark so the marks read as part of it.
|
||||
*/-->
|
||||
|
||||
<th:block th:fragment="inner(branch, name, tag)">
|
||||
<span th:class="'mark mark-r shrink-0 ' + ${branch}" aria-hidden="true"></span>
|
||||
<span class="flex flex-col items-center leading-none min-w-0">
|
||||
<span th:class="'font-lejour ' + ${name}" style="letter-spacing: 0.01em">The Vine</span>
|
||||
<span th:class="'font-adbhashitha uppercase mt-1.5 whitespace-nowrap ' + ${tag}">Coffeehouse + Bakery</span>
|
||||
</span>
|
||||
<span th:class="'mark mark-l shrink-0 ' + ${branch}" aria-hidden="true"></span>
|
||||
</th:block>
|
||||
|
||||
<!--/* Header and footer: a link home. */-->
|
||||
<a th:fragment="small(tint)" href="/"
|
||||
th:class="'flex items-center gap-1.5 sm:gap-2 min-w-0 shrink ' + ${tint}"
|
||||
aria-label="The Vine Coffeehouse + Bakery, home">
|
||||
<th:block th:replace="~{:: inner('w-12 h-12 sm:w-14 sm:h-14', 'text-xl sm:text-2xl md:text-3xl', 'text-[0.6rem] sm:text-xs tracking-[0.18em]')}"></th:block>
|
||||
</a>
|
||||
|
||||
<!--/* The hero, on the homepage — where a link back to "/" would be pointless. */-->
|
||||
<div th:fragment="large(tint)"
|
||||
th:class="'flex items-center justify-center gap-2 sm:gap-4 min-w-0 shrink ' + ${tint}"
|
||||
role="img" aria-label="The Vine Coffeehouse + Bakery">
|
||||
<th:block th:replace="~{:: inner('w-20 h-20 sm:w-28 sm:h-28', 'text-4xl sm:text-5xl md:text-6xl', 'text-xs sm:text-base tracking-[0.2em]')}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<!--/*
|
||||
The shell every page wears: the head, the sticky header, the footer, and the flex column that keeps
|
||||
the footer at the bottom of a short page.
|
||||
|
||||
A page replaces its own <html> with this fragment and passes its content in as `~{::content}` — a
|
||||
reference to the fragment named "content" in the page's own file. That is how a layout works in
|
||||
Thymeleaf without a layout dialect, and it means the chrome exists once: a page that wanted its own
|
||||
header would have to say so.
|
||||
|
||||
Title, description and path are parameters rather than model lookups so that the error pages — which
|
||||
Boot renders through its own controller, with none of SiteController's model — can pass their own.
|
||||
*/-->
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="page(title, description, path, content)">
|
||||
<head th:replace="~{fragments/head :: head(${title}, ${description}, ${path})}"></head>
|
||||
<body>
|
||||
<div class="min-h-screen bg-bakery-50 flex flex-col">
|
||||
<div th:replace="~{fragments/header :: header}"></div>
|
||||
<main class="flex-grow">
|
||||
<div th:replace="${content}"></div>
|
||||
</main>
|
||||
<div th:replace="~{fragments/footer :: footer}"></div>
|
||||
</div>
|
||||
<!--/* The only script on the public site: it gives the multi-photo product cards their arrows and
|
||||
dots. Without it they are still a swipeable strip of photos. */-->
|
||||
<script defer th:src="|/js/gallery.js?v=${build}|"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Our story</h1>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16 md:pb-24">
|
||||
<div class="max-w-2xl mx-auto space-y-12">
|
||||
<section>
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">How it started</h2>
|
||||
<p class="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 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">What we make</h2>
|
||||
<p class="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 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">The cakes are the fun part</h2>
|
||||
<p class="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 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">Around town</h2>
|
||||
<p class="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'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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content">
|
||||
|
||||
<!--/* Hero — centred lockup on a sage wash. */-->
|
||||
<section class="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 th:src="${photos.of('gallery/Outside.webp')}" alt="" fetchpriority="high"
|
||||
class="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 class="absolute inset-0 bg-bakery-900/80"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
|
||||
|
||||
<!--/* 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 class="relative container mx-auto px-4 w-full min-w-0">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<div th:replace="~{fragments/lockup :: large('text-bakery-50 mb-10')}"></div>
|
||||
<p class="text-lg sm:text-xl text-bakery-100 mb-10 text-balance leading-relaxed">
|
||||
A coffeehouse and bakery in downtown Princeville, Illinois.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a href="/products"
|
||||
class="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
|
||||
</a>
|
||||
<a href="#visit"
|
||||
class="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>
|
||||
|
||||
<!--/* What people come in for */-->
|
||||
<section class="py-16 md:py-24 bg-bakery-50">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">What people come in for</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8">
|
||||
<div th:each="item : ${ {'Cinnamon Rolls', 'Sugar Cookies', 'Cakes'} }"
|
||||
class="group text-center bg-white rounded-3xl overflow-hidden shadow-xs hover:shadow-lg hover:-translate-y-1 transition duration-300">
|
||||
<div class="overflow-hidden">
|
||||
<!--/* The filenames carry the spaces in these names; SitePhotos encodes them. */-->
|
||||
<img th:src="${photos.of('gallery/' + item + '.webp')}" th:alt="${item}" width="600" height="400" loading="lazy"
|
||||
class="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
</div>
|
||||
<h3 class="font-adbhashitha text-xl md:text-2xl text-bakery-800 py-6 tracking-wide" th:text="${item}">Cinnamon Rolls</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--/* Our story */-->
|
||||
<section class="py-16 md:py-24 bg-bakery-800 text-white">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<h2 class="font-adbhashitha text-3xl md:text-4xl mb-8" style="letter-spacing: 0.01em">Our story</h2>
|
||||
<p class="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>
|
||||
<a href="/history" class="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
|
||||
Read our story
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--/* Visit us */-->
|
||||
<section id="visit" class="py-16 md:py-24 bg-bakery-50 scroll-mt-24">
|
||||
<div class="container mx-auto px-4">
|
||||
<h2 class="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">Visit us</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-3xl p-6 md:p-8">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Our hours</h2>
|
||||
<ul class="space-y-3 text-bakery-800">
|
||||
<li class="flex justify-between gap-4">
|
||||
<span>Tuesday – Friday</span>
|
||||
<span class="font-medium whitespace-nowrap">7:00am – 2:00pm</span>
|
||||
</li>
|
||||
<li class="flex justify-between gap-4">
|
||||
<span>Saturday</span>
|
||||
<span class="font-medium whitespace-nowrap">7:00am – 12:00pm</span>
|
||||
</li>
|
||||
<li class="flex justify-between gap-4">
|
||||
<span>Sunday – Monday</span>
|
||||
<span class="font-medium">Closed</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-white rounded-3xl p-6 md:p-8">
|
||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Find us</h2>
|
||||
<address class="not-italic space-y-3 text-bakery-800">
|
||||
<p>215 E Main Street<br>Princeville, IL 61559</p>
|
||||
<p>
|
||||
<a href="tel:+13097010660" class="hover:text-bakery-600 underline underline-offset-4">(309) 701-0660</a>
|
||||
</p>
|
||||
<p class="break-words">
|
||||
<a href="mailto:[email protected]" class="hover:text-bakery-600 underline underline-offset-4">[email protected]</a>
|
||||
</p>
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||
|
||||
<header class="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 class="font-adbhashitha text-4xl md:text-5xl text-bakery-900">Our products</h1>
|
||||
</header>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
<!--/*
|
||||
The category filter: links, not buttons. Each filtered view is now a URL you can bookmark or send
|
||||
to somebody, the browser's back button does what it looks like it does, and a crawler can see all
|
||||
forty items instead of whichever twelve the default filter showed. The filtering itself has always
|
||||
been the server's job — this just stopped pretending otherwise.
|
||||
*/-->
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-12">
|
||||
<a th:each="category : ${categories}"
|
||||
th:href="${category == 'All' ? '/products' : '/products?category=' + #uris.escapeQueryParam(category)}"
|
||||
th:text="${category}"
|
||||
th:aria-current="${category == selected ? 'page' : null}"
|
||||
th:class="'px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors '
|
||||
+ (${category == selected}
|
||||
? 'bg-bakery-600 text-white border-bakery-600'
|
||||
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100')">All</a>
|
||||
</div>
|
||||
|
||||
<!--/* Deliberately unanimated, as before: 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. The only motion left is the shadow on hover. */-->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div th:each="product : ${products}"
|
||||
class="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg">
|
||||
<div class="relative w-full overflow-hidden">
|
||||
<div th:replace="~{:: gallery(${product})}"></div>
|
||||
</div>
|
||||
<div class="p-6 text-center">
|
||||
<h3 class="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide" th:text="${product.name}">Name</h3>
|
||||
<span class="text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${product.category}">Category</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p th:if="${#lists.isEmpty(products)}" class="text-center text-bakery-800">
|
||||
Nothing in that category just now.
|
||||
<a href="/products" class="underline underline-offset-4">See everything</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--/*
|
||||
The photo on a card.
|
||||
|
||||
A scroll-snap strip rather than a stack of absolutely positioned images cross-fading: it is one square
|
||||
photo at a time either way, but this version swipes on a phone and scrolls with a trackpad with no
|
||||
script at all. /js/gallery.js adds the arrows and the dots on top. They are created there rather than
|
||||
rendered here on purpose — a control that does nothing without JavaScript is worse than no control.
|
||||
*/-->
|
||||
<div th:fragment="gallery(product)" class="relative aspect-square bg-bakery-100">
|
||||
<div class="gallery flex h-full w-full overflow-x-auto snap-x snap-mandatory no-scrollbar"
|
||||
th:attr="aria-label=${#lists.size(product.images) > 1 ? product.name + ' — ' + #lists.size(product.images) + ' photos' : null}"
|
||||
th:aria-roledescription="${#lists.size(product.images) > 1 ? 'carousel' : null}">
|
||||
<img th:each="image, i : ${product.images}" th:src="${image}"
|
||||
th:alt="${i.index == 0 ? product.name : ''}"
|
||||
th:aria-hidden="${i.index == 0 ? null : 'true'}"
|
||||
loading="lazy" decoding="async"
|
||||
class="snap-center shrink-0 h-full w-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,83 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
/**
|
||||
* Runs against the REAL frontend/index.html rather than a fixture: the controller finds its tags by
|
||||
* pattern, so reformatting that file is exactly how this would silently break. Here it fails the build
|
||||
* instead.
|
||||
*/
|
||||
class PageMetaControllerTest {
|
||||
|
||||
private static final File INDEX = new File("frontend/index.html");
|
||||
|
||||
private static PageMetaController controller() {
|
||||
DefaultResourceLoader loader = new DefaultResourceLoader() {
|
||||
@Override
|
||||
public Resource getResource(String location) {
|
||||
return new FileSystemResource(INDEX);
|
||||
}
|
||||
};
|
||||
return new PageMetaController(loader, "https://itsthevine.com");
|
||||
}
|
||||
|
||||
private static String get(String path) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
|
||||
request.setRequestURI(path);
|
||||
return controller().page(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theIndexTemplateIsWhereTheControllerExpects() {
|
||||
assertThat(INDEX).exists();
|
||||
}
|
||||
|
||||
@Test
|
||||
void productsPageGetsItsOwnTitleAndDescription() {
|
||||
String html = get("/products");
|
||||
|
||||
assertThat(html).contains("<title>Our products · The Vine Coffeehouse + Bakery</title>");
|
||||
assertThat(html).contains("<meta name=\"description\" content=\"Cinnamon rolls, caramel rolls");
|
||||
assertThat(html).contains("<meta property=\"og:title\" content=\"Our products · The Vine");
|
||||
assertThat(html).contains("<meta property=\"og:url\" content=\"https://itsthevine.com/products\">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRouteIsRewritten() {
|
||||
// A route the controller maps but forgot to describe would silently serve the homepage's
|
||||
// metadata, which is worse than none — it tells a crawler two URLs are the same page.
|
||||
assertThat(get("/history")).contains("<title>Our story · ");
|
||||
assertThat(get("/contact")).contains("<title>Contact us · ");
|
||||
assertThat(get("/")).contains("<title>The Vine Coffeehouse + Bakery</title>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theHomepageOgUrlHasNoTrailingSlash() {
|
||||
assertThat(get("/")).contains("<meta property=\"og:url\" content=\"https://itsthevine.com\">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDefaultMetadataSurvivesOnASubPage() {
|
||||
// The template ships with the homepage copy. If a replacement misses, that copy leaks onto
|
||||
// every page and the whole exercise is pointless.
|
||||
String html = get("/contact");
|
||||
assertThat(html).doesNotContain("A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake");
|
||||
assertThat(html).doesNotContain("<title>The Vine Coffeehouse + Bakery</title>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theAppShellIsStillIntact() {
|
||||
// Rewriting the head must not disturb what actually boots the SPA.
|
||||
String html = get("/products");
|
||||
assertThat(html).contains("<div id=\"root\"></div>");
|
||||
assertThat(html).contains("/src/main.tsx");
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,42 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import net.thebennett.platform.test.PlatformWebContract;
|
||||
|
||||
/** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */
|
||||
/**
|
||||
* What this app must do because it is on the platform.
|
||||
*
|
||||
* <p>It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five
|
||||
* assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts
|
||||
* that an unknown extension-less path forwards to {@code /index.html}, because it was written when every
|
||||
* app on the platform was a React SPA. This one is server-rendered now — {@code /index.html} holds
|
||||
* nothing but the admin shell — so forwarding a mistyped URL there would answer with a blank page and a
|
||||
* 200 instead of the site's own 404. The contract's own test methods are package-private, so the
|
||||
* assertion cannot be overridden from here.
|
||||
*
|
||||
* <p>The other four are restated below unchanged, so this app still fails the build on the regression
|
||||
* the contract exists for (an {@code /api} typo answering with a page and a 200).
|
||||
*
|
||||
* <p><b>Platform follow-up:</b> {@code PlatformWebContract} should decide which of the two routing
|
||||
* behaviours to assert by reading {@code platform.web.spa.enabled} — then one contract would cover both
|
||||
* kinds of app and this file could go back to inheriting it.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
// The storage starter activates on its default endpoint, so an S3 client is built even in
|
||||
// tests and fails on blank keys.
|
||||
@@ -19,10 +46,59 @@ import net.thebennett.platform.test.PlatformWebContract;
|
||||
"[email protected]"
|
||||
})
|
||||
@Testcontainers
|
||||
class PlatformContractTest extends PlatformWebContract {
|
||||
class PlatformContractTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an /api path that matches no controller returns 404, not a page")
|
||||
void unknownApiPathIsNotFound() throws Exception {
|
||||
mvc.perform(get("/api/a-path-no-controller-serves")).andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a nested unknown /api path returns 404 too")
|
||||
void unknownNestedApiPathIsNotFound() throws Exception {
|
||||
mvc.perform(get("/api/deeper/still/not/real")).andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("health reports UP")
|
||||
void healthIsUp() throws Exception {
|
||||
mvc.perform(get("/actuator/health"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("UP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("liveness and readiness probes are exposed")
|
||||
void probesAreExposed() throws Exception {
|
||||
// Docker's HEALTHCHECK and any future orchestrator depend on these existing.
|
||||
mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk());
|
||||
mvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown route is a 404, and only /admin serves the SPA shell")
|
||||
void routingIsServerSideExceptForTheAdmin() throws Exception {
|
||||
mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound());
|
||||
// Assert the forward TARGET, not the body: MockMvc records a forward rather than executing it,
|
||||
// so the body is empty here by design — which also means this passes before the admin is built.
|
||||
mvc.perform(get("/admin"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(forwardedUrl("/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/**
|
||||
* The pages, rendered.
|
||||
*
|
||||
* <p>These assert what a visitor and a crawler are actually served — the catalogue in the HTML rather
|
||||
* than in a JSON call the page makes later, and a real per-page {@code <title>}. That second one is the
|
||||
* whole reason {@code PageMetaController} existed; this replaces its test.
|
||||
*
|
||||
* <p>They are also the only thing that catches a broken template: a Thymeleaf expression that names a
|
||||
* model attribute wrongly fails at render time, not at compile time.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"platform.storage.access-key=test",
|
||||
"platform.storage.secret-key=test",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"site.base-url=https://itsthevine.test",
|
||||
"site.assets.base-url=https://s3.example.test/itsthevine"
|
||||
})
|
||||
@Testcontainers
|
||||
class SiteControllerTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyPageStatesItsOwnTitleAndDescription() throws Exception {
|
||||
// One generic shell for every page was the SPA's problem, and the reason a controller used to
|
||||
// rewrite the head with regular expressions.
|
||||
mvc.perform(get("/")).andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("<title>The Vine Coffeehouse + Bakery</title>")))
|
||||
.andExpect(content().string(containsString("A locally owned coffeehouse and bakery")))
|
||||
.andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test\"")));
|
||||
mvc.perform(get("/products"))
|
||||
.andExpect(content().string(containsString("<title>Our products · The Vine Coffeehouse + Bakery</title>")))
|
||||
.andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test/products\"")));
|
||||
mvc.perform(get("/catering"))
|
||||
.andExpect(content().string(containsString("<title>Goodie boxes & catering · The Vine Coffeehouse + Bakery</title>")));
|
||||
mvc.perform(get("/history"))
|
||||
.andExpect(content().string(containsString("<title>Our story · The Vine Coffeehouse + Bakery</title>")));
|
||||
mvc.perform(get("/contact"))
|
||||
.andExpect(content().string(containsString("<title>Contact us · The Vine Coffeehouse + Bakery</title>")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCatalogueIsInTheHtmlRatherThanFetchedAfterwards() throws Exception {
|
||||
mvc.perform(get("/products")).andExpect(status().isOk())
|
||||
// A real product, its category, and a photo URL built from the bucket config.
|
||||
.andExpect(content().string(containsString("76th Birthday Cake")))
|
||||
.andExpect(content().string(containsString("https://s3.example.test/itsthevine/images/")))
|
||||
// The filter buttons are links now, so every filtered view is a URL a crawler can follow.
|
||||
.andExpect(content().string(containsString("href=\"/products?category=Cakes\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCategoryFilterIsAppliedByTheServer() throws Exception {
|
||||
mvc.perform(get("/products").param("category", "Pie")).andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("Blueberry Cream Pie")))
|
||||
.andExpect(content().string(not(containsString("76th Birthday Cake"))))
|
||||
// The chosen one is marked for a screen reader, not just coloured in.
|
||||
.andExpect(content().string(containsString("aria-current=\"page\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCateringTablesAreRenderedFromTheDatabase() throws Exception {
|
||||
mvc.perform(get("/catering")).andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("Office")))
|
||||
.andExpect(content().string(containsString("Weddings")))
|
||||
// Prices as the server writes them — the page never formats money.
|
||||
.andExpect(content().string(containsString("$24")))
|
||||
.andExpect(content().string(containsString("$236")))
|
||||
// A cell, and a note.
|
||||
.andExpect(content().string(containsString("6+6+6 or 12+6")))
|
||||
.andExpect(content().string(containsString("Minimum of 6 items per baked good")))
|
||||
// Both renderings of the same table are present; CSS decides which one is visible.
|
||||
.andExpect(content().string(containsString("<table")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePagesShareOneHeaderThatOffersTheCateringPage() throws Exception {
|
||||
// A page nobody can navigate to isn't finished.
|
||||
mvc.perform(get("/")).andExpect(content().string(containsString("href=\"/catering\"")));
|
||||
mvc.perform(get("/products")).andExpect(content().string(containsString("href=\"/catering\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCateringButtonStartsTheEnquiryOffAboutThatTable() throws Exception {
|
||||
mvc.perform(get("/contact").param("about", "Weddings"))
|
||||
.andExpect(content().string(containsString("like to ask about weddings catering")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAboutParameterThatIsntATableIsIgnored() throws Exception {
|
||||
// The value lands in a box on the page, so it is matched against the real table names rather
|
||||
// than echoed. Thymeleaf escapes it either way; a link that puts someone else's words in front
|
||||
// of a customer still shouldn't work.
|
||||
mvc.perform(get("/contact").param("about", "<script>alert(1)</script>"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(not(containsString("<script>alert(1)</script>"))))
|
||||
.andExpect(content().string(not(containsString("like to ask about"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownPageIsNotFound() throws Exception {
|
||||
// Status only: MockMvc does not run the servlet container's error dispatch, so the body of the
|
||||
// rendered error/404.html page can't be asserted here. It is checked against a running container
|
||||
// instead — the page itself is a template like any other, and the layout it uses is covered above.
|
||||
mvc.perform(get("/no-such-page")).andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||