Catering, and a pure Java/Spring site: Thymeleaf front to back (#10)
build-and-publish / build (push) Successful in 1m39s

This commit was merged in pull request #10.
This commit is contained in:
2026-07-26 21:26:57 -05:00
98 changed files with 7225 additions and 5472 deletions
+24 -4
View File
@@ -40,7 +40,26 @@ jobs:
mvn -B -ntp -s .gitea/ci-settings.xml -DskipFrontend=true verify
# Build + Trivy on every run (PRs included), so a broken Dockerfile or a new HIGH/CRITICAL CVE
# blocks the merge. Only the push is main-only.
# blocks the merge.
#
# A PULL REQUEST MUST NOT TOUCH THE :latest TAG. This runner builds on the host's Docker daemon —
# the same daemon the live container runs on — so retagging :latest IS a deployment: Watchtower
# compares the running container's image against :latest, finds they differ, and recreates the
# container from the locally built image. Gating only the push was not enough; on 2026-07-26 the PR
# builds for this branch deployed unmerged code to itsthevine.com several times over, and each time
# Watchtower's next pull of the (older) registry :latest reverted it. One of those deploys ran a
# migration against the production database.
#
# So a PR builds pr-<number> instead, which nothing watches. Trivy scans whatever was built, and the
# push step below still only runs off a PR.
- name: Choose the image tag
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "IMAGE_TAG=pr-${{ github.event.number }}" >> "$GITHUB_ENV"
else
echo "IMAGE_TAG=latest" >> "$GITHUB_ENV"
fi
- name: Build image
env:
DOCKER_BUILDKIT: "1"
@@ -51,17 +70,18 @@ jobs:
--secret id=maven_user,env=MAVEN_USER \
--secret id=maven_token,env=MAVEN_TOKEN \
--build-arg GIT_SHA=${{ github.sha }} \
-t git.thebennett.net/thevine/itsthevine:latest -t git.thebennett.net/thevine/itsthevine:${{ github.sha }} .
-t "git.thebennett.net/thevine/itsthevine:$IMAGE_TAG" \
-t "git.thebennett.net/thevine/itsthevine:${{ github.sha }}" .
- name: Scan image (Trivy)
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \
git.thebennett.net/thevine/itsthevine:latest || true
"git.thebennett.net/thevine/itsthevine:$IMAGE_TAG" || true
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
--pkg-types library --exit-code 1 --no-progress \
git.thebennett.net/thevine/itsthevine:latest
"git.thebennett.net/thevine/itsthevine:$IMAGE_TAG"
# Publish only on a real push to main (or manual dispatch) — never from a pull request.
- name: Push image
+3 -2
View File
@@ -1,6 +1,7 @@
target/
frontend/node_modules/
frontend/dist/
# The Tailwind CLI's dependencies. The compiled stylesheet needs no rule of its own: it is written
# straight into target/classes/static/css, which is already ignored above.
src/main/styles/node_modules/
.idea/
*.iml
.vscode/
+3
View File
@@ -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"]
+125 -18
View File
@@ -1,36 +1,112 @@
# 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 end to end —
there is no JavaScript framework in this repo. 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`. `src/main/styles` is the whole asset pipeline |
| Admin | Thymeleaf forms at `/admin`, behind Authentik |
| 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 |
### The look, in one place
`src/main/styles` is the whole of it — no bundler, no framework, one stylesheet:
| file | what it holds |
|---|---|
| `theme.css` | the three typefaces and the sage-and-cream palette. Names, nothing drawn |
| `base.css` | bare elements: page background, body type, the fade-in, the focus ring, the z-index scale |
| `type.css` | the typographic ladder (below) |
| `components.css` | `panel`, `pill-*`, `chip`, `section`/`band` — the site's own classes |
| `admin.css` | the admin's controls, as `@utility` so `file:` variants work on the photo pickers |
**The type ladder comes out of the logo.** The lockup is "The Vine" in LeJour Script over COFFEEHOUSE +
BAKERY in AdBhashitha, letterspaced — and everything below is that idea made progressively more readable:
the script stays in the wordmark and nowhere else; AdBhashitha carries `h1``h4` and `price`, keeping the
wordmark's 0.01em letter-spacing so a heading sits on the same rhythm as the logo above it; `eyebrow` and
`label` are the hinge, sans but letterspaced like the tagline; then plain Raleway for anything you have to
read a paragraph of. None of the type classes set a colour — the same heading appears in sage on cream and
cream on sage.
Write those class names in templates rather than the utilities behind them. The full vocabulary:
| | |
|---|---|
| surfaces | `panel`, `panel-lift`, `panel-head`, `panel-mark`, `photo` |
| buttons | `pill-sage`, `pill-cream`, `pill-outline`, `pill-ghost`, `chip`/`chip-on` |
| links | `link`, `link-plain`, `link-on-dark` |
| type | `h1``h4`, `price`, `eyebrow`, `label`, `lede`, `wordmark` |
| layout | `container`, `measure`, `measure-wide`, `section`, `band`, `page-head` |
| forms | `field` — one input for the public form and the admin |
Four colourways of one button exist because the site puts buttons on cream *and* on sage. Corners come
from `--radius-panel` (12px) and `--radius-field` (8px), so "how round is a card" is one decision.
If you find yourself writing `bg-white rounded-… shadow-…` or `underline underline-offset-4` in a
template, there is already a name for it.
### 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` so a React SPA can own routing, and there is no SPA here — leaving it on would answer a
mistyped URL with a blank page and a 200 instead of the site's own 404.
## 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`.
- **`/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.
- **`/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
Forms and redirects. Every write is a POST followed by a redirect back to the page, so the back button
and reload do what they look like they do, a double-tap can't repeat an upload, and there is no
client-side state to lose — a reload is always the truth. Two screens: `/admin` is the catalogue,
`/admin/catering` lists the price tables and `/admin/catering/tables/{id}` edits one.
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.
@@ -39,11 +115,31 @@ Photos are resized, stripped of EXIF, converted to webp and put in the bucket on
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
webp or ship glibc natives that don't run on Alpine).
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and
`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
a bare 401 to handle.
The catering tables are edited a table at a time rather than a field at a time. That isn't a taste in
interfaces: a column heading, its price and the entries beneath it only mean anything together, so
`CateringPackage#arrange` takes the whole table and refuses one whose lines and columns disagree. Drop
the middle column on its own and every remaining entry shifts one place left — the Large box then
advertises the Medium box's contents at the Large price, and nothing about the page looks broken.
**How that works without JavaScript.** One form holds the whole table and every button in it submits
that form; `name="do"` says which was pressed and its value carries the position it applies to
(`remove-column:2`). So "add a column" arrives with every cell the editor has typed, adds the column to
what arrived — plus an empty entry on every line — and re-renders. Nothing typed is lost, and **only
Save writes**: a half-built table with a blank column heading never reaches the live page, and the
aggregate would refuse it anyway. A failed save comes back the same way, with the work still in the
form and the reason above it, because a redirect would throw the work away and leave the editor guessing
which cell the message was about.
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminController` and `AdminCateringController` are
`@ConditionalOnProperty` on it, so a deployment that forgets to configure Authentik gets 404s rather than
catalogue writes open to the internet. `/admin/**` is an authenticated path, so a browser opening it is
sent to Authentik and comes back signed in. There is no JSON admin any more: `AdminProductController`,
`AdminCategoryController` and the old `/api/admin/**` endpoints existed for the React screen and went
with it. Their logic lives in `Catalogue` and `CateringMenu`, which the pages call.
**Testing a protected page needs `Accept: text/html`.** curl and MockMvc both send `*/*`, which the
platform answers with a bare 401; only a request that prefers HTML gets the 302 to Authentik. Asserting
the 401 and calling the page broken is a mistake worth not making twice.
Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the
bucket. Harmless — nothing links to it — but it accumulates.
@@ -57,14 +153,24 @@ 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, admin included (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
# the stylesheet, while editing templates — watches and recompiles
cd src/main/styles && npm install && npm run watch
```
Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`.
`/admin` only exists when `SECURITY_MODE=OIDC`, so a plain local run has the site and no admin. To work
on the admin without an identity provider, run with `SECURITY_MODE=OIDC`, dummy
`spring.security.oauth2.client.*` values (see `AdminPagesTest` for a set that starts without touching
the network) and `platform.security.authenticated-paths=/nothing/**` so nothing asks you to sign in.
`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):
@@ -81,7 +187,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 |
-29
View File
@@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
<!-- 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">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-2853
View File
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
{
"name": "itsthevine-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"motion": "12.42.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "7.18.1"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"tailwindcss": "4.3.3",
"typescript": "^7.0.0",
"vite": "^8.1.3",
"vite-plugin-svgr": "^5.0.0"
}
}
-66
View File
@@ -1,66 +0,0 @@
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.
*/
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>
</>
);
export default App;
-52
View File
@@ -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;
-118
View File
@@ -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;
-64
View File
@@ -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;
-143
View File
@@ -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;
Binary file not shown.
Binary file not shown.
-85
View File
@@ -1,85 +0,0 @@
@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);
}
-126
View File
@@ -1,126 +0,0 @@
/**
* The catalogue, its ordering, its category filter and its image URLs are all decided by the
* backend — this file just fetches them. Same origin, so no base URL and no CORS.
*/
export interface Product {
id: number;
name: string;
category: string;
/** Absolute, ready to put in a src. Built server-side from the bucket config. */
images: string[];
}
/** What the admin screens get back: the public shape plus where it sits in the order. */
export interface AdminProduct extends Product {
position: number;
/** The same photos as `images`, in the same order — these are what arrangePhotos names them by. */
keys: string[];
}
export interface AdminCategory {
id: number;
name: string;
position: number;
/** How many products are filed under it — deleting one that's in use is refused. */
used: number;
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(path, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
return res.json() as Promise<T>;
}
export const fetchProducts = (category?: string) =>
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
export const fetchCategories = () => get<string[]>('/api/categories');
/**
* Spring hands the SPA a CSRF token in a cookie and wants it echoed on anything that writes. Read
* per request rather than cached: it rotates on sign-in, and a stale token fails exactly like a
* missing one. Returns nothing when security is off, which is why the contact form still posts
* happily on a deployment with no identity provider.
*/
export function csrfHeader(): Record<string, string> {
const token = document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.slice('XSRF-TOKEN='.length);
return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
}
/**
* Every admin write funnels through here so one place understands the server's failure shapes: a
* 401/403 means the session lapsed (the OIDC chain answers /api with a status rather than bouncing
* you to a login page), and anything else carries a ProblemDetail whose `detail` is the sentence
* the server wants the editor to read.
*/
async function send<T>(path: string, method: string, body?: unknown, form?: FormData): Promise<T> {
const res = await fetch(path, {
method,
headers: {
Accept: 'application/json',
...(form ? {} : { 'Content-Type': 'application/json' }),
...csrfHeader(),
},
body: form ?? (body === undefined ? undefined : JSON.stringify(body)),
});
if (res.status === 401 || res.status === 403) {
throw new Error('Your sign-in has expired — refresh the page to sign in again.');
}
if (!res.ok) {
const problem = await res.json().catch(() => null);
throw new Error(problem?.detail || problem?.error || 'That did not save. Please try again.');
}
return (res.status === 204 ? undefined : await res.json()) as T;
}
// --- products ---------------------------------------------------------------
export const adminProducts = () => get<AdminProduct[]>('/api/admin/products');
export function createProduct(name: string, category: string, photos: File[]) {
const form = new FormData();
form.append('name', name);
form.append('category', category);
photos.forEach((p) => form.append('photos', p));
return send<AdminProduct>('/api/admin/products', 'POST', undefined, form);
}
export const describeProduct = (id: number, name: string, category: string) =>
send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', { name, category });
export const deleteProduct = (id: number) =>
send<{ ok: boolean }>(`/api/admin/products/${id}`, 'DELETE');
export function addPhotos(id: number, photos: File[]) {
const form = new FormData();
photos.forEach((p) => form.append('photos', p));
return send<AdminProduct>(`/api/admin/products/${id}/photos`, 'POST', undefined, form);
}
/** The full arrangement the editor is looking at — removing a photo is just an omission. */
export const arrangePhotos = (id: number, keys: string[]) =>
send<AdminProduct>(`/api/admin/products/${id}/photos`, 'PUT', keys);
export const reorderProducts = (ids: number[]) =>
send<AdminProduct[]>('/api/admin/products/order', 'PUT', { ids });
// --- categories -------------------------------------------------------------
export const adminCategories = () => get<AdminCategory[]>('/api/admin/categories');
export const createCategory = (name: string) =>
send<AdminCategory>('/api/admin/categories', 'POST', { name });
export const renameCategory = (id: number, name: string) =>
send<AdminCategory>(`/api/admin/categories/${id}`, 'PUT', { name });
export const reorderCategories = (ids: number[]) =>
send<AdminCategory[]>('/api/admin/categories/order', 'PUT', { ids });
export const deleteCategory = (id: number) =>
send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE');
-17
View File
@@ -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}`;
}
-13
View File
@@ -1,13 +0,0 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
-716
View File
@@ -1,716 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
addPhotos,
adminCategories,
adminProducts,
arrangePhotos,
createCategory,
createProduct,
deleteCategory,
deleteProduct,
describeProduct,
renameCategory,
reorderCategories,
reorderProducts,
type AdminCategory,
type AdminProduct,
} from '@/lib/api';
/**
* The catalogue, editable by the person who bakes it.
*
* The whole screen is built around one rule: nothing waits on the network to look like it happened.
* Reordering and deleting apply to the list on screen first and reconcile afterwards, because an
* editor tidying twenty items shouldn't be typing into a page that freezes between every click.
* Uploads are the exception — they genuinely take a moment (resize, convert, send), so they say so.
*
* Getting here at all means signing in: /admin is an authenticated path, so an unknown visitor is
* sent to the identity provider before this ever loads.
*/
// --- little pieces ----------------------------------------------------------
const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={`w-4 h-4 ${className}`}
aria-hidden="true"
>
<path d={d} />
</svg>
);
const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
const PLUS = 'M12 5v14M5 12h14';
const CHECK = 'M20 6L9 17l-5-5';
const X = 'M18 6L6 18M6 6l12 12';
const button =
'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' +
'transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
const danger = `${button} text-red-700 hover:bg-red-50`;
const iconButton =
'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' +
'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
const field =
'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' +
'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500';
/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */
function shift<T>(items: T[], index: number, delta: number): T[] {
const target = index + delta;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
}
// --- photos -----------------------------------------------------------------
/**
* The photos on one item. Order matters — the first is the one the products page leads with — so
* arranging is left/right rather than a drag target, which is far easier to hit on a phone.
*/
const Photos = ({
product,
onArrange,
busy,
}: {
product: AdminProduct;
onArrange: (keys: string[]) => void;
busy: boolean;
}) => (
<div className="flex flex-wrap gap-3">
{product.images.map((url, i) => (
<figure key={product.keys[i] ?? url} className="w-28">
<img
src={url}
alt=""
loading="lazy"
className="w-28 h-28 rounded-md object-cover border border-bakery-200 bg-bakery-100"
/>
<figcaption className="mt-1 flex items-center justify-between gap-1">
<div className="flex gap-1">
<button
type="button"
className={iconButton}
disabled={busy || i === 0}
onClick={() => onArrange(shift(product.keys, i, -1))}
aria-label="Move photo earlier"
>
<Icon d={ARROW_LEFT} />
</button>
<button
type="button"
className={iconButton}
disabled={busy || i === product.images.length - 1}
onClick={() => onArrange(shift(product.keys, i, 1))}
aria-label="Move photo later"
>
<Icon d={ARROW_RIGHT} />
</button>
</div>
<button
type="button"
className={iconButton}
// The server refuses an empty arrangement; saying so up front beats an error message.
disabled={busy || product.images.length === 1}
onClick={() => onArrange(product.keys.filter((_, at) => at !== i))}
aria-label="Remove photo"
title={product.images.length === 1 ? 'An item needs at least one photo' : 'Remove photo'}
>
<Icon d={TRASH} />
</button>
</figcaption>
</figure>
))}
</div>
);
/** A file picker styled as a button, resetting itself so the same file can be chosen twice. */
const PhotoPicker = ({
label,
onPick,
disabled,
className = secondary,
}: {
label: string;
onPick: (files: File[]) => void;
disabled?: boolean;
className?: string;
}) => {
const input = useRef<HTMLInputElement>(null);
return (
<>
<input
ref={input}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = '';
if (files.length) onPick(files);
}}
/>
<button type="button" className={className} disabled={disabled} onClick={() => input.current?.click()}>
<Icon d={PLUS} />
{label}
</button>
</>
);
};
// --- one item ---------------------------------------------------------------
const ProductCard = ({
product,
categories,
first,
last,
onChange,
onMove,
onDelete,
onError,
}: {
product: AdminProduct;
categories: string[];
first: boolean;
last: boolean;
onChange: (updated: AdminProduct) => void;
onMove: (delta: number) => void;
onDelete: () => void;
onError: (message: string) => void;
}) => {
const [name, setName] = useState(product.name);
const [category, setCategory] = useState(product.category);
const [busy, setBusy] = useState(false);
// A reorder or an upload re-fetches this product; the fields should follow unless they're being
// edited, which is what the dirty check below decides.
const dirty = name !== product.name || category !== product.category;
const [synced, setSynced] = useState(product);
if (synced !== product) {
setSynced(product);
if (!dirty) {
setName(product.name);
setCategory(product.category);
}
}
const run = async (work: () => Promise<AdminProduct>) => {
setBusy(true);
try {
onChange(await work());
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<li className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex sm:flex-col gap-1 sm:pt-1">
<button
type="button"
className={iconButton}
disabled={first}
onClick={() => onMove(-1)}
aria-label={`Move ${product.name} up`}
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={last}
onClick={() => onMove(1)}
aria-label={`Move ${product.name} down`}
>
<Icon d={ARROW_DOWN} />
</button>
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-2 sm:grid-cols-[1fr_12rem]">
<label className="block">
<span className="sr-only">Name</span>
<input
className={field}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
</label>
<label className="block">
<span className="sr-only">Category</span>
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
{/* A product can sit in a category nobody defined; don't silently retype it. */}
{!categories.includes(category) && <option value={category}>{category}</option>}
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
</div>
<Photos
product={product}
busy={busy}
onArrange={(keys) => run(() => arrangePhotos(product.id, keys))}
/>
<div className="flex flex-wrap items-center gap-2">
<PhotoPicker
label="Add photos"
disabled={busy}
onPick={(files) => run(() => addPhotos(product.id, files))}
/>
{dirty && (
<>
<button
type="button"
className={primary}
disabled={busy}
onClick={() => run(() => describeProduct(product.id, name.trim(), category))}
>
<Icon d={CHECK} />
Save
</button>
<button
type="button"
className={secondary}
disabled={busy}
onClick={() => {
setName(product.name);
setCategory(product.category);
}}
>
<Icon d={X} />
Cancel
</button>
</>
)}
<button type="button" className={`${danger} ml-auto`} disabled={busy} onClick={onDelete}>
<Icon d={TRASH} />
Delete
</button>
</div>
{busy && <p className="text-sm text-bakery-600">Working</p>}
</div>
</div>
</li>
);
};
// --- adding an item ---------------------------------------------------------
const NewItem = ({
categories,
onAdded,
onError,
}: {
categories: string[];
onAdded: (product: AdminProduct) => void;
onError: (message: string) => void;
}) => {
const [name, setName] = useState('');
const [category, setCategory] = useState(categories[0] ?? '');
const [files, setFiles] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
// The category list arrives after the first render, so the default has to catch up once.
useEffect(() => {
setCategory((c) => (c || categories[0] || ''));
}, [categories]);
const submit = async () => {
setBusy(true);
try {
onAdded(await createProduct(name.trim(), category, files));
setName('');
setFiles([]);
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<h2 className="font-adbhashitha text-xl text-bakery-800">Add something new</h2>
<p className="mt-1 text-sm text-bakery-600">New items go to the top of the products page.</p>
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_12rem]">
<input
className={field}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="What is it? e.g. Chocolate drip cake"
/>
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
{files.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{files.map((file, i) => (
<div key={`${file.name}-${i}`} className="relative">
<img
src={URL.createObjectURL(file)}
alt=""
className="w-20 h-20 rounded-md object-cover border border-bakery-200"
// Revoking once it's painted keeps the preview from holding the file in memory.
onLoad={(e) => URL.revokeObjectURL(e.currentTarget.src)}
/>
<button
type="button"
className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-white border border-bakery-300 text-bakery-700 flex items-center justify-center"
onClick={() => setFiles(files.filter((_, at) => at !== i))}
aria-label={`Remove ${file.name}`}
>
<Icon d={X} className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<PhotoPicker
label={files.length ? 'Add more photos' : 'Choose photos'}
disabled={busy}
onPick={(picked) => setFiles((current) => [...current, ...picked])}
/>
<button
type="button"
className={primary}
disabled={busy || !name.trim() || !category || files.length === 0}
onClick={submit}
>
<Icon d={PLUS} />
{busy ? 'Uploading…' : 'Add to the page'}
</button>
{busy && <span className="text-sm text-bakery-600">Photos can take a few seconds each.</span>}
</div>
</section>
);
};
// --- categories -------------------------------------------------------------
const Categories = ({
categories,
setCategories,
onError,
onChanged,
}: {
categories: AdminCategory[];
setCategories: (next: AdminCategory[]) => void;
onError: (message: string) => void;
onChanged: () => void;
}) => {
const [fresh, setFresh] = useState('');
const [editing, setEditing] = useState<number | null>(null);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const guard = async (work: () => Promise<unknown>, optimistic?: AdminCategory[]) => {
const before = categories;
if (optimistic) setCategories(optimistic);
setBusy(true);
try {
await work();
onChanged();
} catch (e) {
if (optimistic) setCategories(before);
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<h2 className="font-adbhashitha text-xl text-bakery-800">Categories</h2>
<p className="mt-1 text-sm text-bakery-600">
These are the filter buttons on the products page, in this order. Renaming one moves
everything filed under it too.
</p>
<ul className="mt-3 divide-y divide-bakery-100">
{categories.map((category, i) => (
<li key={category.id} className="flex items-center gap-2 py-2">
<div className="flex gap-1">
<button
type="button"
className={iconButton}
disabled={busy || i === 0}
onClick={() =>
guard(
() => reorderCategories(shift(categories, i, -1).map((c) => c.id)),
shift(categories, i, -1),
)
}
aria-label={`Move ${category.name} up`}
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={busy || i === categories.length - 1}
onClick={() =>
guard(
() => reorderCategories(shift(categories, i, 1).map((c) => c.id)),
shift(categories, i, 1),
)
}
aria-label={`Move ${category.name} down`}
>
<Icon d={ARROW_DOWN} />
</button>
</div>
{editing === category.id ? (
<>
<input
className={field}
value={draft}
autoFocus
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && setEditing(null)}
/>
<button
type="button"
className={primary}
disabled={busy || !draft.trim()}
onClick={() =>
guard(async () => {
await renameCategory(category.id, draft.trim());
setEditing(null);
})
}
>
<Icon d={CHECK} />
Save
</button>
<button type="button" className={secondary} onClick={() => setEditing(null)}>
<Icon d={X} />
</button>
</>
) : (
<>
<span className="flex-1 text-bakery-900">{category.name}</span>
<span className="text-sm text-bakery-500">
{category.used} item{category.used === 1 ? '' : 's'}
</span>
<button
type="button"
className={secondary}
onClick={() => {
setEditing(category.id);
setDraft(category.name);
}}
>
Rename
</button>
<button
type="button"
className={danger}
disabled={busy || category.used > 0}
title={category.used > 0 ? 'Move its items somewhere else first' : 'Delete'}
onClick={() =>
guard(
() => deleteCategory(category.id),
categories.filter((c) => c.id !== category.id),
)
}
>
<Icon d={TRASH} />
</button>
</>
)}
</li>
))}
</ul>
<div className="mt-3 flex gap-2">
<input
className={field}
value={fresh}
onChange={(e) => setFresh(e.target.value)}
placeholder="New category"
onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && guard(async () => {
await createCategory(fresh.trim());
setFresh('');
})}
/>
<button
type="button"
className={primary}
disabled={busy || !fresh.trim()}
onClick={() =>
guard(async () => {
await createCategory(fresh.trim());
setFresh('');
})
}
>
<Icon d={PLUS} />
Add
</button>
</div>
</section>
);
};
// --- the page ---------------------------------------------------------------
const AdminPage = () => {
const [products, setProducts] = useState<AdminProduct[] | null>(null);
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [error, setError] = useState('');
const load = useCallback(async () => {
try {
const [items, cats] = await Promise.all([adminProducts(), adminCategories()]);
setProducts(items);
setCategories(cats);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not load the catalogue.');
setProducts([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const names = categories.map((c) => c.name);
/** Reorder and delete both apply on screen first — the point of this page is that it keeps up. */
const settle = async (optimistic: AdminProduct[], work: () => Promise<unknown>) => {
const before = products ?? [];
setProducts(optimistic);
try {
await work();
} catch (e) {
setProducts(before);
setError(e instanceof Error ? e.message : 'That did not save.');
}
};
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
<p className="text-bakery-600">Everything on the products page lives here.</p>
</div>
<div className="flex items-center gap-2">
<a href="/products" className={secondary}>
View the page
</a>
{/* A real form post: the platform's logout expects one, and it also ends the Authentik session. */}
<form method="post" action="/logout">
<button type="submit" className={secondary}>
Sign out
</button>
</form>
</div>
</header>
{error && (
<div
role="alert"
className="mt-6 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
>
<span className="flex-1">{error}</span>
<button type="button" onClick={() => setError('')} aria-label="Dismiss">
<Icon d={X} />
</button>
</div>
)}
{products === null ? (
<p className="mt-10 text-bakery-600">Loading</p>
) : (
<div className="mt-6 space-y-6">
<Categories
categories={categories}
setCategories={setCategories}
onError={setError}
// A rename rewrites the products filed under it, so the list has to come back fresh.
onChanged={() => void load()}
/>
<NewItem
categories={names}
onError={setError}
onAdded={(product) => setProducts([product, ...products])}
/>
<section>
<h2 className="font-adbhashitha text-xl text-bakery-800">
On the page ({products.length})
</h2>
<ul className="mt-3 space-y-3">
{products.map((product, i) => (
<ProductCard
key={product.id}
product={product}
categories={names}
first={i === 0}
last={i === products.length - 1}
onError={setError}
onChange={(updated) =>
setProducts(products.map((p) => (p.id === updated.id ? updated : p)))
}
onMove={(delta) => {
const moved = shift(products, i, delta);
void settle(moved, () => reorderProducts(moved.map((p) => p.id)));
}}
onDelete={() => {
if (!confirm(`Remove ${product.name} from the products page?`)) return;
void settle(
products.filter((p) => p.id !== product.id),
() => deleteProduct(product.id),
);
}}
/>
))}
</ul>
{products.length === 0 && (
<p className="mt-3 text-bakery-600">Nothing here yet add something above.</p>
)}
</section>
</div>
)}
</div>
);
};
export default AdminPage;
-127
View File
@@ -1,127 +0,0 @@
import { useState } from 'react';
import { csrfHeader } from '@/lib/api';
type Status = 'idle' | 'sending' | 'sent' | 'error';
const ContactPage = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [status, setStatus] = useState<Status>('idle');
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('sending');
setError('');
try {
// csrfHeader() is empty unless security is switched on, so this posts the same as it always
// has on a deployment with no identity provider — and keeps working once one is configured,
// where an unaccompanied POST would otherwise be rejected.
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...csrfHeader() },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
setStatus('sent');
setFormData({ name: '', email: '', message: '' });
} catch (err) {
// Never claim success we did not get. Tell them, and give them the phone number.
setStatus('error');
setError(err instanceof Error ? err.message : 'Could not send the message.');
}
};
return (
<div className="min-h-screen bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Contact us
</h1>
</header>
{/* Contact Form Section */}
<div className="container mx-auto px-4 pb-16 md:pb-24">
<div className="max-w-2xl mx-auto bg-white p-8 md:p-10 rounded-3xl shadow-xs">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-2">Get in touch</h2>
<p className="text-bakery-700 mb-8">
Or call us: <a href="tel:+13097010660" className="underline underline-offset-4 hover:text-bakery-600">(309) 701-0660</a>
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
rows={5}
required
/>
</div>
<div className="flex flex-col items-center gap-4">
<button
type="submit"
disabled={status === 'sending'}
className="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors disabled:opacity-60"
>
{status === 'sending' ? 'Sending...' : 'Send message'}
</button>
{status === 'sent' && (
<p role="status" className="text-bakery-700">
Thanks. Your message is on its way, and we will get back to you.
</p>
)}
{status === 'error' && (
<p role="alert" className="text-center text-red-700">
{error} Please call us on{' '}
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
</p>
)}
</div>
</form>
</div>
</div>
</div>
);
};
export default ContactPage;
-59
View File
@@ -1,59 +0,0 @@
const HistoryPage = () => {
return (
<div className="bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Our story
</h1>
</header>
{/* Story */}
<div className="container mx-auto px-4 pb-16 md:pb-24">
<div className="max-w-2xl mx-auto space-y-12">
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">How it started</h2>
<p className="text-bakery-800 leading-relaxed">
Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown
Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep
enough tables that nobody feels rushed out the door.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">What we make</h2>
<p className="text-bakery-800 leading-relaxed">
We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls
and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and
paninis once the lunch crowd shows up.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">The cakes are the fun part</h2>
<p className="text-bakery-800 leading-relaxed">
Cakes and decorated cookies are made to order, which means we mostly bake whatever
Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a
retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for
the cross country team and for a bridal party. If you can describe it, we will have a go
at it.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">Around town</h2>
<p className="text-bakery-800 leading-relaxed">
We turn out for Christmas in the Village every year and for other civic events, and the
Princeville Civic Association counts us among the town&apos;s small businesses. Enjoy
Illinois and Discover Peoria have both pointed travelers our way. If you are one of them,
we open at 7:00am, Tuesday through Saturday.
</p>
</section>
</div>
</div>
</div>
);
};
export default HistoryPage;
-133
View File
@@ -1,133 +0,0 @@
import { Link } from 'react-router-dom';
import Logo from '@/components/Logo';
import { photo } from '@/lib/assets';
const HomePage = () => {
return (
<div>
{/* Hero — centred lockup on a sage wash. */}
<section className="relative flex items-center min-h-[78svh] py-20 md:py-28 bg-bakery-900 text-white overflow-hidden">
{/* Softened on purpose: the storefront's own painted sign sits right behind
the logo, so a sharp photo makes you read the name twice. scale-110 hides
the blur's feathered edges. */}
<img
src={photo('gallery/Outside.webp')}
alt=""
fetchPriority="high"
className="absolute inset-0 w-full h-full object-cover object-bottom blur-[3px] scale-110"
/>
{/* Sage wash rather than a neutral black scrim — the tint is the identity. */}
<div className="absolute inset-0 bg-bakery-900/80" />
<div className="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80" />
{/* w-full/min-w-0 keep this flex item from sizing to its max-content
width and blowing out the page on narrow screens. */}
<div className="relative container mx-auto px-4 w-full min-w-0">
<div className="max-w-3xl mx-auto text-center">
<Logo size="lg" linked={false} className="text-bakery-50 mb-10 justify-center" />
<p className="text-lg sm:text-xl text-bakery-100 mb-10 text-balance leading-relaxed">
A coffeehouse and bakery in downtown Princeville, Illinois.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
to="/products"
className="bg-bakery-50 hover:bg-white text-bakery-900 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition shadow-lg"
>
See the menu
</Link>
<a
href="#visit"
className="border border-bakery-200/60 hover:bg-bakery-50/10 text-bakery-50 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
>
Visit us
</a>
</div>
</div>
</div>
</section>
{/* Our Specialties */}
<section className="py-16 md:py-24 bg-bakery-50">
<div className="container mx-auto px-4">
<h2 className="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">What people come in for</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8">
{['Cinnamon Rolls', 'Sugar Cookies', 'Cakes'].map((item) => (
<div
key={item}
className="group text-center bg-white rounded-3xl overflow-hidden shadow-xs hover:shadow-lg hover:-translate-y-1 transition duration-300"
>
<div className="overflow-hidden">
<img
src={photo(`gallery/${item}.webp`)}
alt={item}
width={600}
height={400}
loading="lazy"
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<h3 className="font-adbhashitha text-xl md:text-2xl text-bakery-800 py-6 tracking-wide">{item}</h3>
</div>
))}
</div>
</div>
</section>
{/* About Us */}
<section className="py-16 md:py-24 bg-bakery-800 text-white">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto text-center">
<h2 className="font-adbhashitha text-3xl md:text-4xl mb-8" style={{ letterSpacing: '0.01em' }}>Our story</h2>
<p className="text-base md:text-lg text-bakery-100 mb-8 leading-relaxed">
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
</p>
<Link to="/history" className="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
Read our story
</Link>
</div>
</div>
</section>
{/* Hours & Contact */}
<section id="visit" className="py-16 md:py-24 bg-bakery-50 scroll-mt-24">
<div className="container mx-auto px-4">
<h2 className="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">Visit us</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-4xl mx-auto">
<div className="bg-white rounded-3xl p-6 md:p-8">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Our hours</h2>
<ul className="space-y-3 text-bakery-800">
<li className="flex justify-between gap-4">
<span>Tuesday Friday</span>
<span className="font-medium whitespace-nowrap">7:00am 2:00pm</span>
</li>
<li className="flex justify-between gap-4">
<span>Saturday</span>
<span className="font-medium whitespace-nowrap">7:00am 12:00pm</span>
</li>
<li className="flex justify-between gap-4">
<span>Sunday Monday</span>
<span className="font-medium">Closed</span>
</li>
</ul>
</div>
<div className="bg-white rounded-3xl p-6 md:p-8">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Find us</h2>
<address className="not-italic space-y-3 text-bakery-800">
<p>215 E Main Street<br />Princeville, IL 61559</p>
<p>
<a href="tel:+13097010660" className="hover:text-bakery-600 underline underline-offset-4">(309) 701-0660</a>
</p>
<p className="break-words">
<a href="mailto:[email protected]" className="hover:text-bakery-600 underline underline-offset-4">contact@itsthevine.com</a>
</p>
</address>
</div>
</div>
</div>
</section>
</div>
);
};
export default HomePage;
-30
View File
@@ -1,30 +0,0 @@
import { Link } from 'react-router-dom';
const NotFoundPage = () => (
<div className="bg-bakery-50">
<div className="container mx-auto px-4 py-24 md:py-32 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">
We could not find that page
</h1>
<p className="text-bakery-800 mb-10">
It may have moved. The menu, our story, and how to reach us are all still here.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
to="/"
className="bg-bakery-600 hover:bg-bakery-700 text-white px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
>
Back home
</Link>
<Link
to="/products"
className="border border-bakery-300 hover:bg-bakery-100 text-bakery-700 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
>
See the menu
</Link>
</div>
</div>
</div>
);
export default NotFoundPage;
-94
View File
@@ -1,94 +0,0 @@
import { useEffect, useState } from 'react';
import { fetchCategories, fetchProducts, type Product } from '@/lib/api';
import ProductGallery from '@/components/ProductGallery';
const ProductsPage = () => {
const [categories, setCategories] = useState<string[]>(['All']);
const [selectedCategory, setSelectedCategory] = useState('All');
const [products, setProducts] = useState<Product[]>([]);
const [failed, setFailed] = useState(false);
useEffect(() => {
fetchCategories().then(setCategories).catch(() => setFailed(true));
}, []);
// The filter is applied by the API, not in the browser — one source of truth for what's in a
// category. `ignore` drops a slow response that lost the race to a newer click.
useEffect(() => {
let ignore = false;
setFailed(false);
fetchProducts(selectedCategory)
.then((p) => { if (!ignore) setProducts(p); })
.catch(() => { if (!ignore) setFailed(true); });
return () => { ignore = true; };
}, [selectedCategory]);
return (
<div className="min-h-screen bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Our products
</h1>
</header>
{/* Products Section */}
<div className="container mx-auto px-4 pb-16">
{/* Categories */}
<div className="flex flex-wrap justify-center gap-4 mb-12">
{categories.map((category) => (
<button
key={category}
type="button"
onClick={() => setSelectedCategory(category)}
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
selectedCategory === category
? 'bg-bakery-600 text-white border-bakery-600'
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100'
}`}
>
{category}
</button>
))}
</div>
{failed && (
<p role="alert" className="text-center text-bakery-800">
We could not load the menu just now. Please refresh, or call us on{' '}
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
</p>
)}
{/* Products Grid — deliberately unanimated. Filtering used to run a `layout` reflow plus
an enter/exit fade on every card, which on a 40-card grid reads as the page lurching
rather than responding. Swapping the list outright is instant, and the only motion
left is the shadow on hover. */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<div
key={product.id}
className="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg"
>
{/* Image Container */}
<div className="relative w-full overflow-hidden">
<ProductGallery images={product.images} alt={product.name} />
</div>
{/* Content */}
<div className="p-6 text-center">
<h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide">
{product.name}
</h3>
<span className="text-xs uppercase tracking-[0.15em] text-bakery-600">
{product.category}
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default ProductsPage;
-17
View File
@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client", "vite-plugin-svgr/client", "node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
-26
View File
@@ -1,26 +0,0 @@
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';
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()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 2024,
// Backend on :8080 during development; the built SPA 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,
},
});
+49 -1
View File
@@ -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,14 +124,55 @@
<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). -->
<!--
There is no JavaScript application here any more, so node is present for one reason:
Tailwind needs a compiler, and the alternative to it is a hand-written stylesheet.
src/main/styles is the whole asset pipeline (see its site.css).
-->
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<configuration>
<!-- Overrides platform-parent's `frontend`, which was where the SPA lived. The
stylesheet sources live with the rest of the app's source; node_modules has to
sit beside them, because that is where Tailwind resolves its own import from. -->
<workingDirectory>src/main/styles</workingDirectory>
</configuration>
<executions>
<!--
Tailwind, over every template, straight into the build output.
process-classes, not prepare-package: `mvn spring-boot:run` stops at
process-classes, and bound any later a local run would serve an unstyled site. The
output goes to target/classes/static/css so the generated file can never be
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>
<!-- Inherited from platform-parent to build an SPA at prepare-package. There isn't
one; the stylesheet is already built above. -->
<execution>
<id>npm-build</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<!-- Same: this copied frontend/dist into the jar's static/. No dist, nothing to
copy — the pages are templates and the stylesheet is written straight to
target/classes/static/css. -->
<execution>
<id>copy-frontend</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
@@ -1,151 +0,0 @@
package com.itsthevine.web;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.Category;
import com.itsthevine.web.domain.CategoryRepository;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
/**
* The filter buttons, editable. Gated on OIDC for the same reason as the product admin: with no
* identity provider configured these endpoints shouldn't exist at all.
*/
@RestController
@RequestMapping("/api/admin/categories")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminCategoryController {
private final CategoryRepository categories;
private final ProductRepository products;
public AdminCategoryController(CategoryRepository categories, ProductRepository products) {
this.categories = categories;
this.products = products;
}
/** {@code used} tells the editor whether deleting it would strand anything. */
public record AdminView(Long id, String name, int position, long used) {}
public record Name(String name) {}
public record Order(List<Long> ids) {}
@GetMapping
@Transactional(readOnly = true)
public List<AdminView> list() {
List<Product> all = products.findAllByOrderByPositionAsc();
return categories.findAllByOrderByPositionAsc().stream()
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(all, c.getName())))
.toList();
}
@PostMapping
@Transactional
public AdminView create(@RequestBody Name body) {
String name = required(body.name());
categories.findByNameIgnoreCase(name).ifPresent(existing -> {
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
});
int last = categories.findAllByOrderByPositionAsc().stream()
.mapToInt(Category::getPosition).max().orElse(0);
Category saved = categories.save(new Category(name, last + 1));
return new AdminView(saved.getId(), saved.getName(), saved.getPosition(), 0);
}
/**
* Renaming carries the products with it. They store the category by name, so without this the
* rename would orphan everything filed under the old one — it would drop off the filter and
* reappear at the end as an unlisted category.
*/
@PutMapping("/{id}")
@Transactional
public AdminView rename(@PathVariable Long id, @RequestBody Name body) {
Category category = find(id);
String name = required(body.name());
categories.findByNameIgnoreCase(name)
.filter(other -> !other.getId().equals(id))
.ifPresent(other -> {
throw new IllegalStateException("There's already a " + other.getName() + " category.");
});
String previous = category.getName();
category.rename(name);
categories.save(category);
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
filed.forEach(p -> p.describe(p.getName(), name));
products.saveAll(filed);
return new AdminView(category.getId(), category.getName(), category.getPosition(), filed.size());
}
@PutMapping("/order")
@Transactional
public List<AdminView> reorder(@RequestBody Order order) {
List<Category> all = categories.findAllByOrderByPositionAsc();
List<Category> arranged = new ArrayList<>();
for (Long id : order.ids()) {
all.stream().filter(c -> c.getId().equals(id)).findFirst().ifPresent(arranged::add);
}
all.stream().filter(c -> !arranged.contains(c)).forEach(arranged::add);
int position = 1;
for (Category category : arranged) {
category.moveTo(position++);
}
categories.saveAll(arranged);
List<Product> everything = products.findAllByOrderByPositionAsc();
return arranged.stream()
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(everything, c.getName())))
.toList();
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
Category category = find(id);
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
if (used > 0) {
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
// the items behind it.
throw new IllegalStateException(
used + " item" + (used == 1 ? " is" : "s are") + " still filed under "
+ category.getName() + ". Move them first.");
}
categories.delete(category);
return ResponseEntity.ok(Map.of("ok", true));
}
private Category find(Long id) {
return categories.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
}
private static long count(List<Product> all, String category) {
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
}
private static String required(String value) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("Please give the category a name.");
}
return trimmed;
}
}
@@ -0,0 +1,332 @@
package com.itsthevine.web;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* The catering price tables, editable as forms.
*
* <p>A table is edited and saved whole, which is the same rule the React screen followed and for the
* same reason: a column heading, its price and the entries beneath it only mean anything together, so
* they have to be added, moved and removed together. {@code CateringPackage#arrange} refuses an
* arrangement whose lines and columns disagree.
*
* <p>The interesting part is doing that without JavaScript. One form holds the whole table, and its
* buttons all submit it — {@code name="do"} says which one was pressed. "Add a column" therefore arrives
* with every cell the editor has typed so far, adds the column to what arrived, and re-renders; nothing
* typed is lost, and nothing is written until Save. The alternative — a link that adds a column
* server-side — would have to either discard the unsaved edits or write a half-built table to the live
* page.
*/
@Controller
@RequestMapping("/admin/catering")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminCateringController {
private final CateringMenu catering;
public AdminCateringController(CateringMenu catering) {
this.catering = catering;
}
/**
* One table, as the form posts it back.
*
* <p>A form-backing object rather than a pile of {@code @RequestParam} lists, because the cells are a
* grid: Spring binds {@code lines[2].values[1]} into exactly the right place, whereas flat repeated
* parameters would rely on the browser's submission order to keep the grid square.
*/
public static class TableForm {
private String name = "";
private String blurb = "";
private List<ColumnForm> columns = new ArrayList<>();
private List<LineForm> lines = new ArrayList<>();
private List<String> notes = new ArrayList<>();
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBlurb() { return blurb; }
public void setBlurb(String blurb) { this.blurb = blurb; }
public List<ColumnForm> getColumns() { return columns; }
public void setColumns(List<ColumnForm> columns) { this.columns = columns; }
public List<LineForm> getLines() { return lines; }
public void setLines(List<LineForm> lines) { this.lines = lines; }
public List<String> getNotes() { return notes; }
public void setNotes(List<String> notes) { this.notes = notes; }
}
public static class ColumnForm {
/** Null for a column the editor has just added and not yet saved. */
private Long id;
private String label = "";
private String price = "";
private String serves = "";
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getLabel() { return label; }
public void setLabel(String label) { this.label = label; }
public String getPrice() { return price; }
public void setPrice(String price) { this.price = price; }
public String getServes() { return serves; }
public void setServes(String serves) { this.serves = serves; }
}
public static class LineForm {
private Long id;
private String label = "";
private List<String> values = new ArrayList<>();
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getLabel() { return label; }
public void setLabel(String label) { this.label = label; }
public List<String> getValues() { return values; }
public void setValues(List<String> values) { this.values = values; }
}
/** The tables, listed: rename or fill one in on its own page, and set the page's own notes here. */
@GetMapping
public String tables(Model model) {
model.addAttribute("menu", catering.everything());
return "admin/catering";
}
/**
* One table's editor.
*
* <p>A page per table rather than every table on one screen: a table is saved whole, so the thing
* being edited and the thing being saved should be the same thing you can see.
*/
@GetMapping("/tables/{id}")
public String edit(@PathVariable Long id, Model model) {
CateringMenu.PackageView table = catering.everything().packages().stream()
.filter(p -> p.id().equals(id))
.findFirst()
.orElse(null);
if (table == null) {
return "redirect:/admin/catering";
}
model.addAttribute("table", formOf(table));
model.addAttribute("tableId", id);
return "admin/table";
}
@PostMapping("/tables")
public String add(@RequestParam String name, RedirectAttributes flash) {
try {
catering.add(name);
flash.addFlashAttribute("done", "Added the " + name.trim() + " table. Give it a column and a line.");
} catch (IllegalArgumentException | IllegalStateException e) {
flash.addFlashAttribute("problem", e.getMessage());
}
return "redirect:/admin/catering";
}
/**
* Save, or restructure and come back.
*
* @param action which button was pressed: {@code save}, {@code add-column}, {@code add-line}, or one
* of {@code remove-column}/{@code move-column}/{@code remove-line}/{@code move-line}
* with the position after a colon ({@code move-column:2:-1}). A button can only send
* its own name and value, so the value carries the argument.
*/
@PostMapping("/tables/{id}")
public String save(@PathVariable Long id,
@ModelAttribute("table") TableForm form,
@RequestParam(name = "do", defaultValue = "save") String action,
Model model,
RedirectAttributes flash) {
if (!action.equals("save")) {
restructure(form, action);
// Deliberately NOT a redirect: this is a draft, not a saved state. Re-rendering the form the
// editor is looking at keeps every cell they have typed; writing it now would put a column
// headed "" on the live page, and the table refuses that anyway.
model.addAttribute("tableId", id);
model.addAttribute("unsaved", true);
return "admin/table";
}
try {
catering.save(id, new CateringMenu.PackageEdit(
form.getName(),
form.getBlurb(),
form.getColumns().stream()
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice(), c.getServes()))
.toList(),
form.getLines().stream()
.map(l -> new CateringMenu.RowEdit(l.getId(), l.getLabel(), l.getValues()))
.toList(),
form.getNotes()));
flash.addFlashAttribute("done", "Saved the " + form.getName().trim() + " table.");
return "redirect:/admin/catering";
} catch (IllegalArgumentException | IllegalStateException e) {
// Back to the form with what they typed, and the reason. A redirect here would throw away the
// work and leave them guessing which cell the message was about.
model.addAttribute("tableId", id);
model.addAttribute("unsaved", true);
model.addAttribute("problem", e.getMessage());
return "admin/table";
}
}
@PostMapping("/tables/{id}/move")
public String move(@PathVariable Long id, @RequestParam int by) {
List<Long> ids = new ArrayList<>(catering.everything().packages().stream()
.map(CateringMenu.PackageView::id).toList());
int at = ids.indexOf(id);
int to = at + by;
if (at >= 0 && to >= 0 && to < ids.size()) {
swap(ids, at, to);
catering.reorder(ids);
}
return "redirect:/admin/catering";
}
@PostMapping("/tables/{id}/delete")
public String remove(@PathVariable Long id, RedirectAttributes flash) {
try {
catering.remove(id);
flash.addFlashAttribute("done", "Table deleted.");
} catch (IllegalArgumentException | IllegalStateException e) {
flash.addFlashAttribute("problem", e.getMessage());
}
return "redirect:/admin/catering";
}
@PostMapping("/notes")
public String notes(@RequestParam(name = "notes", required = false) List<String> notes,
RedirectAttributes flash) {
try {
catering.replaceNotes(notes == null ? List.of() : notes);
flash.addFlashAttribute("done", "Saved the notes for the page.");
} catch (IllegalArgumentException | IllegalStateException e) {
flash.addFlashAttribute("problem", e.getMessage());
}
return "redirect:/admin/catering";
}
/**
* Applies a structural button to the draft that arrived.
*
* <p>Adding a column adds an empty entry to every line, and removing one takes its entries with it,
* which is the invariant the aggregate insists on. Doing it here rather than in the browser is the
* whole point: there is one implementation of "a table has as many entries per line as it has
* columns", and it is in Java.
*/
private static void restructure(TableForm form, String action) {
String[] parts = action.split(":");
String what = parts[0];
int at = parts.length > 1 ? Integer.parseInt(parts[1]) : -1;
int by = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
switch (what) {
case "add-column" -> {
form.getColumns().add(new ColumnForm());
form.getLines().forEach(line -> line.getValues().add(""));
}
case "remove-column" -> {
if (inRange(at, form.getColumns().size())) {
form.getColumns().remove(at);
form.getLines().forEach(line -> {
if (inRange(at, line.getValues().size())) {
line.getValues().remove(at);
}
});
}
}
case "move-column" -> {
int to = at + by;
if (inRange(at, form.getColumns().size()) && inRange(to, form.getColumns().size())) {
swap(form.getColumns(), at, to);
form.getLines().forEach(line -> swap(line.getValues(), at, to));
}
}
case "add-line" -> {
LineForm line = new LineForm();
form.getColumns().forEach(column -> line.getValues().add(""));
form.getLines().add(line);
}
case "remove-line" -> {
if (inRange(at, form.getLines().size())) {
form.getLines().remove(at);
}
}
case "move-line" -> {
int to = at + by;
if (inRange(at, form.getLines().size()) && inRange(to, form.getLines().size())) {
swap(form.getLines(), at, to);
}
}
case "add-note" -> form.getNotes().add("");
case "remove-note" -> {
if (inRange(at, form.getNotes().size())) {
form.getNotes().remove(at);
}
}
// An unknown action is a stale page or a hand-edited form: leave the draft exactly as it is
// rather than guessing at an edit nobody asked for.
default -> { }
}
}
/** The stored table, as a form to edit. */
private static TableForm formOf(CateringMenu.PackageView table) {
TableForm form = new TableForm();
form.setName(table.name());
form.setBlurb(table.blurb() == null ? "" : table.blurb());
form.setColumns(table.tiers().stream().map(tier -> {
ColumnForm column = new ColumnForm();
column.setId(tier.id());
column.setLabel(tier.label());
// The price comes back written out ("$24") and goes out again as whatever is left in the box;
// Money reads either.
column.setPrice(tier.price() == null ? "" : tier.price());
column.setServes(tier.serves() == null ? "" : tier.serves());
return column;
}).collect(Collectors.toCollection(ArrayList::new)));
form.setLines(table.rows().stream().map(row -> {
LineForm line = new LineForm();
line.setId(row.id());
line.setLabel(row.label());
line.setValues(new ArrayList<>(row.values()));
return line;
}).collect(Collectors.toCollection(ArrayList::new)));
form.setNotes(new ArrayList<>(table.notes()));
return form;
}
private static boolean inRange(int at, int size) {
return at >= 0 && at < size;
}
private static <T> void swap(List<T> items, int a, int b) {
T held = items.get(a);
items.set(a, items.get(b));
items.set(b, held);
}
}
@@ -0,0 +1,173 @@
package com.itsthevine.web;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* The catalogue, editable by the person who bakes it — as pages and form posts.
*
* <p>Every write is POST, then a redirect back to the page it came from. That is not ceremony: it means
* the browser's back button and reload do what they look like they do, a double-tap can't repeat an
* upload, and there is no client-side state to lose. The message the editor reads afterwards travels as
* a flash attribute.
*
* <p>The whole controller is conditional on OIDC being switched on, the same as the JSON admin it
* replaced. That is deliberate belt-and-braces: the platform's permit-all filter chain is what runs when
* {@code platform.security.mode} is unset, so if these pages existed unconditionally a deployment that
* forgot to configure Authentik would be publishing catalogue writes to the open internet. Gated this
* way, "no auth configured" means "no admin" — the paths 404 like any other unknown URL.
*/
@Controller
@RequestMapping("/admin")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminController {
private final Catalogue catalogue;
public AdminController(Catalogue catalogue) {
this.catalogue = catalogue;
}
@GetMapping
public String catalogue(Model model) {
model.addAttribute("items", catalogue.items());
model.addAttribute("filters", catalogue.filters());
return "admin/catalogue";
}
// --- items ---------------------------------------------------------------
@PostMapping("/items")
public String add(@RequestParam String name,
@RequestParam String category,
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
RedirectAttributes flash) {
return run(flash, () -> {
catalogue.addItem(name, category, photos);
flash.addFlashAttribute("done", "Added " + name.trim() + " to the top of the page.");
});
}
/**
* Everything you can do to one item, from one form.
*
* <p>One form per item rather than one per button: the page carries forty items, and a separate form
* for each control meant 467 forms and 464 CSRF tokens — 317 KB of HTML for a screen that is opened
* on a phone, in a bakery. {@code name="do"} says which button was pressed and its value carries the
* argument, exactly as the catering table editor does.
*
* <p>Only {@code save} looks at the name and category boxes. The other actions deliberately ignore
* them, so pressing "move down" halfway through retyping a name doesn't save the half-typed name.
*
* @param action {@code save}, {@code move:-1}, {@code move:1}, {@code delete}, or
* {@code photo:<key>:<-1|1|0>} — earlier, later, or remove
*/
@PostMapping("/items/{id}")
public String item(@PathVariable Long id,
@RequestParam(name = "do", defaultValue = "save") String action,
@RequestParam(required = false) String name,
@RequestParam(required = false) String category,
RedirectAttributes flash) {
String[] parts = action.split(":");
return run(flash, () -> {
switch (parts[0]) {
case "save" -> {
catalogue.describeItem(id, name, category);
flash.addFlashAttribute("done", "Saved " + name.trim() + ".");
}
case "move" -> catalogue.moveItem(id, Integer.parseInt(parts[1]));
case "delete" -> {
catalogue.removeItem(id);
flash.addFlashAttribute("done", "Removed from the products page.");
}
case "photo" -> {
// The key is the middle field; it can contain slashes and dots but never a colon.
String key = parts[1];
int move = Integer.parseInt(parts[2]);
if (move == 0) {
catalogue.removePhoto(id, key);
} else {
catalogue.movePhoto(id, key, move);
}
}
// A stale page, or a hand-edited form. Do nothing rather than guess.
default -> { }
}
});
}
@PostMapping("/items/{id}/photos")
public String addPhotos(@PathVariable Long id,
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
RedirectAttributes flash) {
return run(flash, () -> {
catalogue.addPhotos(id, photos);
flash.addFlashAttribute("done", "Photos added.");
});
}
// --- filters -------------------------------------------------------------
@PostMapping("/categories")
public String addFilter(@RequestParam String name, RedirectAttributes flash) {
return run(flash, () -> {
catalogue.addFilter(name);
flash.addFlashAttribute("done", "Added the " + name.trim() + " category.");
});
}
/**
* Everything you can do to one filter, from one form — same shape as an item.
*
* @param action {@code save}, {@code move:-1}, {@code move:1} or {@code delete}
*/
@PostMapping("/categories/{id}")
public String filter(@PathVariable Long id,
@RequestParam(name = "do", defaultValue = "save") String action,
@RequestParam(required = false) String name,
RedirectAttributes flash) {
String[] parts = action.split(":");
return run(flash, () -> {
switch (parts[0]) {
case "save" -> {
catalogue.renameFilter(id, name);
flash.addFlashAttribute("done",
"Renamed to " + name.trim() + ", and everything filed under it moved too.");
}
case "move" -> catalogue.moveFilter(id, Integer.parseInt(parts[1]));
case "delete" -> {
catalogue.removeFilter(id);
flash.addFlashAttribute("done", "Category deleted.");
}
default -> { }
}
});
}
/**
* Runs one edit and comes back to the page.
*
* <p>The two exception types are the vocabulary the domain already speaks — {@code
* IllegalArgumentException} for "that isn't a usable value", {@code IllegalStateException} for "not
* while things are like this" — and both carry a sentence written for the editor to read. The
* platform's exception handler turns them into JSON for the API; here they belong on the page.
*/
private String run(RedirectAttributes flash, Runnable edit) {
try {
edit.run();
} catch (IllegalArgumentException | IllegalStateException e) {
flash.addFlashAttribute("problem", e.getMessage());
}
return "redirect:/admin";
}
}
@@ -1,206 +0,0 @@
package com.itsthevine.web;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
/**
* Editing the catalogue from the site, so a new cake is a photo and a name rather than a migration.
*
* The whole controller is conditional on OIDC being switched on. That is deliberate belt-and-braces:
* the platform's permit-all filter chain is what runs when {@code platform.security.mode} is unset,
* so if these endpoints existed unconditionally a deployment that forgot to configure Authentik
* would be publishing catalogue writes to the open internet. Gated this way, "no auth configured"
* means "no admin endpoints" — they 404 like any other unknown path, which is also what the platform
* web contract expects of {@code /api/**}.
*/
@RestController
@RequestMapping("/api/admin/products")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminProductController {
private final ProductRepository products;
private final ProductPhotoService photos;
private final ProductCatalog catalog;
public AdminProductController(ProductRepository products, ProductPhotoService photos, ProductCatalog catalog) {
this.products = products;
this.photos = photos;
this.catalog = catalog;
}
/**
* What the editor sees: the catalogue in display order.
*
* {@code images} and {@code keys} are the same photos in the same order — the URLs to show and the
* identifiers to arrange by. The public view only needs the former, but an editor rearranging
* photos has to name them back to us, and the URL is a rendering of the key rather than the key
* itself.
*/
public record AdminView(Long id, String name, String category, int position,
List<String> images, List<String> keys) {}
public record Details(String name, String category) {}
public record Order(List<Long> ids) {}
@GetMapping
@Transactional(readOnly = true)
public List<AdminView> list() {
return products.findAllByOrderByPositionAsc().stream().map(this::toView).toList();
}
/**
* New items go to the front — the newest work is what's worth showing first, and it saves the
* editor a reorder after every upload.
*/
@PostMapping
@Transactional
public AdminView create(@RequestParam String name,
@RequestParam String category,
@RequestParam("photos") List<MultipartFile> files) {
String cleanName = required(name, "Please give it a name.");
String cleanCategory = required(category, "Please choose a category.");
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("Please add at least one photo.");
}
List<String> keys = new ArrayList<>();
for (MultipartFile file : files) {
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
}
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
renumberWithFirst(saved);
return toView(saved);
}
@PutMapping("/{id}")
@Transactional
public AdminView describe(@PathVariable Long id, @RequestBody Details details) {
Product product = find(id);
product.describe(required(details.name(), "Please give it a name."),
required(details.category(), "Please choose a category."));
return toView(products.save(product));
}
@PostMapping("/{id}/photos")
@Transactional
public AdminView addPhotos(@PathVariable Long id, @RequestParam("photos") List<MultipartFile> files) {
Product product = find(id);
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("Please choose a photo to add.");
}
List<String> keys = new ArrayList<>(product.getImageKeys());
for (MultipartFile file : files) {
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
}
product.replacePhotos(keys);
return toView(products.save(product));
}
/**
* Reordering and removal both arrive as the full list the editor arranged, so the stored order is
* whatever they last saw rather than the result of replaying moves.
*/
@PutMapping("/{id}/photos")
@Transactional
public AdminView arrangePhotos(@PathVariable Long id, @RequestBody List<String> keys) {
Product product = find(id);
List<String> existing = product.getImageKeys();
List<String> arranged = keys.stream().filter(existing::contains).distinct().toList();
if (arranged.isEmpty()) {
throw new IllegalArgumentException("An item needs at least one photo.");
}
product.replacePhotos(arranged);
return toView(products.save(product));
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
products.delete(find(id));
return ResponseEntity.ok(Map.of("ok", true));
}
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
@PutMapping("/order")
@Transactional
public List<AdminView> reorder(@RequestBody Order order) {
List<Product> all = products.findAllByOrderByPositionAsc();
List<Product> arranged = new ArrayList<>();
for (Long id : order.ids()) {
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
}
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
renumber(arranged);
return arranged.stream().map(this::toView).toList();
}
private void renumberWithFirst(Product first) {
List<Product> arranged = new ArrayList<>();
arranged.add(first);
products.findAllByOrderByPositionAsc().stream()
.filter(p -> !p.getId().equals(first.getId()))
.forEach(arranged::add);
renumber(arranged);
}
/**
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
* swap — forty rows, once in a while, from one editor.
*/
private void renumber(List<Product> arranged) {
int position = 1;
for (Product product : arranged) {
product.moveTo(position++);
}
products.saveAll(arranged);
}
private Product find(Long id) {
return products.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
}
private static byte[] bytes(MultipartFile file) {
try {
return file.getBytes();
} catch (IOException e) {
throw new IllegalStateException("Could not read the uploaded photo.", e);
}
}
private static String required(String value, String message) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(message);
}
return trimmed;
}
/** Reuses the catalogue's URL building so admin and public pages can never disagree about a photo. */
private AdminView toView(Product product) {
ProductCatalog.ProductView view = catalog.view(product);
return new AdminView(view.id(), view.name(), view.category(), product.getPosition(),
view.images(), List.copyOf(product.getImageKeys()));
}
}
@@ -0,0 +1,306 @@
package com.itsthevine.web;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.itsthevine.web.domain.Category;
import com.itsthevine.web.domain.CategoryRepository;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
/**
* Editing the catalogue: what's on the products page, in what order, under which filter, with which
* photos.
*
* <p>This is the logic that used to sit in {@code AdminProductController} and
* {@code AdminCategoryController} when the admin was a React screen talking JSON. The screen is now
* server-rendered forms, and a form can only POST — so "move this up" arrives as an action rather than
* as the whole re-ordered list the browser had arranged. The reordering therefore happens here, which is
* where it should always have been: the browser was only ever telling us what it had already worked out.
*/
@Service
public class Catalogue {
private final ProductRepository products;
private final CategoryRepository categories;
private final ProductPhotoService photos;
private final SitePhotos urls;
public Catalogue(ProductRepository products, CategoryRepository categories,
ProductPhotoService photos, SitePhotos urls) {
this.products = products;
this.categories = categories;
this.photos = photos;
this.urls = urls;
}
/**
* A photo, as the editor needs it: the URL to look at and the key to name it by. Same pairing the
* React screen kept as two parallel arrays — a template can just walk the pairs.
*/
public record Photo(String key, String url) {}
public record Item(Long id, String name, String category, List<Photo> photos) {}
/** {@code used} tells the editor whether deleting a filter would strand anything. */
public record Filter(Long id, String name, long used) {}
@Transactional(readOnly = true)
public List<Item> items() {
return products.findAllByOrderByPositionAsc().stream().map(this::toItem).toList();
}
@Transactional(readOnly = true)
public List<Filter> filters() {
List<Product> all = products.findAllByOrderByPositionAsc();
return categories.findAllByOrderByPositionAsc().stream()
.map(c -> new Filter(c.getId(), c.getName(), count(all, c.getName())))
.toList();
}
// --- items ---------------------------------------------------------------
/**
* New items go to the front — the newest work is what's worth showing first, and it saves the editor
* a reorder after every upload.
*/
@Transactional
public void addItem(String name, String category, List<MultipartFile> files) {
String cleanName = required(name, "Please give it a name.");
String cleanCategory = required(category, "Please choose a category.");
List<MultipartFile> chosen = real(files);
if (chosen.isEmpty()) {
throw new IllegalArgumentException("Please add at least one photo.");
}
List<String> keys = new ArrayList<>();
for (MultipartFile file : chosen) {
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
}
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
List<Product> arranged = new ArrayList<>();
arranged.add(saved);
products.findAllByOrderByPositionAsc().stream()
.filter(p -> !p.getId().equals(saved.getId()))
.forEach(arranged::add);
renumber(arranged);
}
@Transactional
public void describeItem(Long id, String name, String category) {
Product product = item(id);
product.describe(required(name, "Please give it a name."),
required(category, "Please choose a category."));
products.save(product);
}
@Transactional
public void addPhotos(Long id, List<MultipartFile> files) {
Product product = item(id);
List<MultipartFile> chosen = real(files);
if (chosen.isEmpty()) {
throw new IllegalArgumentException("Please choose a photo to add.");
}
List<String> keys = new ArrayList<>(product.getImageKeys());
for (MultipartFile file : chosen) {
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
}
product.replacePhotos(keys);
products.save(product);
}
/** Order matters: the first photo is the one the products page leads with. */
@Transactional
public void movePhoto(Long id, String key, int delta) {
Product product = item(id);
List<String> keys = new ArrayList<>(product.getImageKeys());
int at = keys.indexOf(key);
if (at < 0) {
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
}
int to = at + delta;
if (to < 0 || to >= keys.size()) {
// Already at an end. Nothing to do, and nothing to complain about — the button that asked
// for this is disabled in the page anyway.
return;
}
swap(keys, at, to);
product.replacePhotos(keys);
products.save(product);
}
@Transactional
public void removePhoto(Long id, String key) {
Product product = item(id);
List<String> keys = new ArrayList<>(product.getImageKeys());
if (!keys.remove(key)) {
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
}
if (keys.isEmpty()) {
// The card would have nothing to show. Deleting the item is a different, deliberate act.
throw new IllegalArgumentException("An item needs at least one photo.");
}
product.replacePhotos(keys);
products.save(product);
}
@Transactional
public void removeItem(Long id) {
products.delete(item(id));
}
@Transactional
public void moveItem(Long id, int delta) {
List<Product> all = products.findAllByOrderByPositionAsc();
int at = at(all.stream().map(Product::getId).toList(), id, "That item no longer exists.");
int to = at + delta;
if (to < 0 || to >= all.size()) {
return;
}
swap(all, at, to);
renumber(all);
}
// --- filters -------------------------------------------------------------
@Transactional
public void addFilter(String name) {
String clean = required(name, "Please give the category a name.");
categories.findByNameIgnoreCase(clean).ifPresent(existing -> {
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
});
int last = categories.findAllByOrderByPositionAsc().stream()
.mapToInt(Category::getPosition).max().orElse(0);
categories.save(new Category(clean, last + 1));
}
/**
* Renaming carries the products with it. They store the category by name, so without this the rename
* would orphan everything filed under the old one — it would drop off the filter and reappear at the
* end as an unlisted category.
*/
@Transactional
public void renameFilter(Long id, String name) {
Category category = filter(id);
String clean = required(name, "Please give the category a name.");
categories.findByNameIgnoreCase(clean)
.filter(other -> !other.getId().equals(id))
.ifPresent(other -> {
throw new IllegalStateException("There's already a " + other.getName() + " category.");
});
String previous = category.getName();
category.rename(clean);
categories.save(category);
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
filed.forEach(p -> p.describe(p.getName(), clean));
products.saveAll(filed);
}
@Transactional
public void moveFilter(Long id, int delta) {
List<Category> all = categories.findAllByOrderByPositionAsc();
int at = at(all.stream().map(Category::getId).toList(), id, "That category no longer exists.");
int to = at + delta;
if (to < 0 || to >= all.size()) {
return;
}
swap(all, at, to);
int position = 1;
for (Category category : all) {
category.moveTo(position++);
}
categories.saveAll(all);
}
@Transactional
public void removeFilter(Long id) {
Category category = filter(id);
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
if (used > 0) {
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
// the items behind it.
throw new IllegalStateException(used + " item" + (used == 1 ? " is" : "s are")
+ " still filed under " + category.getName() + ". Move them first.");
}
categories.delete(category);
}
// --- plumbing ------------------------------------------------------------
/** Reuses the catalogue's URL building so the admin and the shop front agree about a photo. */
private Item toItem(Product product) {
List<Photo> pictures = product.getImageKeys().stream()
.map(key -> new Photo(key, urls.of(key)))
.toList();
return new Item(product.getId(), product.getName(), product.getCategory(), pictures);
}
/**
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
* swap — forty rows, once in a while, from one editor.
*/
private void renumber(List<Product> arranged) {
int position = 1;
for (Product product : arranged) {
product.moveTo(position++);
}
products.saveAll(arranged);
}
private Product item(Long id) {
return products.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
}
private Category filter(Long id) {
return categories.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
}
private static int at(List<Long> ids, Long id, String gone) {
int at = ids.indexOf(id);
if (at < 0) {
throw new IllegalArgumentException(gone);
}
return at;
}
private static <T> void swap(List<T> items, int a, int b) {
T held = items.get(a);
items.set(a, items.get(b));
items.set(b, held);
}
private static long count(List<Product> all, String category) {
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
}
/** An empty file input still posts a part, with no filename and no bytes. */
private static List<MultipartFile> real(List<MultipartFile> files) {
return files == null ? List.of() : files.stream().filter(f -> !f.isEmpty()).toList();
}
private static byte[] bytes(MultipartFile file) {
try {
return file.getBytes();
} catch (IOException e) {
throw new IllegalStateException("Could not read the uploaded photo.", e);
}
}
private static String required(String value, String message) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(message);
}
return trimmed;
}
}
@@ -0,0 +1,20 @@
package com.itsthevine.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** The goodie box and catering tables, as a customer reads them. */
@RestController
public class CateringController {
private final CateringMenu menu;
public CateringController(CateringMenu menu) {
this.menu = menu;
}
@GetMapping("/api/catering")
public CateringMenu.MenuView catering() {
return menu.menu();
}
}
@@ -0,0 +1,197 @@
package com.itsthevine.web;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.itsthevine.web.domain.CateringNote;
import com.itsthevine.web.domain.CateringNoteRepository;
import com.itsthevine.web.domain.CateringPackage;
import com.itsthevine.web.domain.CateringPackageRepository;
/**
* The catering page's brains: which tables to show, in what order, what's in each, and what the
* prices read as.
*
* <p>Both the public page and the admin come through here, so there is one answer to "what does this
* table say" — an editor can never arrange something that reads differently once it's live. Prices
* arrive as whatever the editor typed and leave as text that's ready to print; {@code Money} is the
* only thing that decides either, because how much something costs is the shop's business and not
* the browser's.
*/
@Service
public class CateringMenu {
private static final int MOST_NOTES = 12;
private final CateringPackageRepository packages;
private final CateringNoteRepository notes;
public CateringMenu(CateringPackageRepository packages, CateringNoteRepository notes) {
this.packages = packages;
this.notes = notes;
}
/**
* A column. {@code price} is ready to print ("$24") and null when the column doesn't state one;
* {@code serves} is roughly how many people it feeds, and null when the label already says.
*/
public record TierView(Long id, String label, String price, String serves) {}
/** A line, with one entry per column, in column order. */
public record RowView(Long id, String label, List<String> values) {}
public record PackageView(Long id, String name, String blurb,
List<TierView> tiers, List<RowView> rows, List<String> notes) {}
/** The page: every table, plus the terms that apply to all of them. */
public record MenuView(List<PackageView> packages, List<String> notes) {}
// What the admin screen sends back. A whole table at a time — see CateringPackage#arrange.
// `price` is the raw text from the box ("24", "$24.50", ""); Money decides what it means.
public record TierEdit(Long id, String label, String price, String serves) {}
public record RowEdit(Long id, String label, List<String> values) {}
public record PackageEdit(String name, String blurb, List<TierEdit> tiers, List<RowEdit> rows,
List<String> notes) {}
/**
* What a customer sees.
*
* <p>A table with no columns or no lines is left out. 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 — an empty price table tells a customer nothing except that we're disorganised.
*/
@Transactional(readOnly = true)
public MenuView menu() {
List<PackageView> published = packages.findAllByOrderByPositionAsc().stream()
.filter(p -> !p.getTiers().isEmpty() && !p.getRows().isEmpty())
.map(CateringMenu::toView)
.toList();
return new MenuView(published, pageNotes());
}
/** What the editor sees: the same tables, including any they haven't finished. */
@Transactional(readOnly = true)
public MenuView everything() {
List<PackageView> all = packages.findAllByOrderByPositionAsc().stream()
.map(CateringMenu::toView)
.toList();
return new MenuView(all, pageNotes());
}
/** A new, empty table at the end of the page. Columns and lines come next, from the editor. */
@Transactional
public PackageView add(String name) {
int last = packages.findAllByOrderByPositionAsc().stream()
.mapToInt(CateringPackage::getPosition).max().orElse(0);
return toView(packages.save(new CateringPackage(name, last + 1)));
}
/**
* The whole table as the editor left it. Rejected in full or saved in full: the aggregate checks
* every column and line before it touches anything, and the transaction covers the rest.
*/
@Transactional
public PackageView save(Long id, PackageEdit edit) {
CateringPackage table = find(id);
table.describe(edit.name(), edit.blurb());
table.replaceNotes(clean(edit.notes()));
table.arrange(
orEmpty(edit.tiers()).stream()
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price(), t.serves()))
.toList(),
orEmpty(edit.rows()).stream()
.map(r -> new CateringPackage.Line(r.id(), r.label(), orEmpty(r.values())))
.toList());
return toView(packages.save(table));
}
@Transactional
public void remove(Long id) {
packages.delete(find(id));
}
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
@Transactional
public List<PackageView> reorder(List<Long> ids) {
List<CateringPackage> all = packages.findAllByOrderByPositionAsc();
List<CateringPackage> arranged = new ArrayList<>();
for (Long id : orEmpty(ids)) {
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
}
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
int position = 1;
for (CateringPackage table : arranged) {
table.moveTo(position++);
}
packages.saveAll(arranged);
return arranged.stream().map(CateringMenu::toView).toList();
}
/**
* The page's own footnotes, replaced by the list the editor is looking at — so removing one is an
* omission, exactly as it is everywhere else in this admin.
*
* <p>The notes that came back are reused in place rather than deleted and re-inserted, so editing
* a typo doesn't quietly restamp when the terms were written.
*/
@Transactional
public List<String> replaceNotes(List<String> bodies) {
List<String> wanted = clean(bodies);
if (wanted.size() > MOST_NOTES) {
throw new IllegalArgumentException(
"That's a lot of small print — " + MOST_NOTES + " notes at most.");
}
List<CateringNote> existing = notes.findAllByOrderByPositionAsc();
List<CateringNote> keeping = new ArrayList<>();
for (int i = 0; i < wanted.size(); i++) {
CateringNote note = i < existing.size() ? existing.get(i) : new CateringNote(wanted.get(i), i + 1);
note.say(wanted.get(i));
note.moveTo(i + 1);
keeping.add(note);
}
if (existing.size() > wanted.size()) {
notes.deleteAll(existing.subList(wanted.size(), existing.size()));
}
notes.saveAll(keeping);
return keeping.stream().map(CateringNote::getBody).toList();
}
private List<String> pageNotes() {
return notes.findAllByOrderByPositionAsc().stream().map(CateringNote::getBody).toList();
}
private CateringPackage find(Long id) {
return packages.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That table no longer exists."));
}
private static PackageView toView(CateringPackage table) {
List<TierView> tiers = table.getTiers().stream()
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice(), t.getServes()))
.toList();
List<RowView> rows = table.getRows().stream()
.map(r -> new RowView(r.getId(), r.getLabel(), r.getValues()))
.toList();
return new PackageView(table.getId(), table.getName(), table.getBlurb(), tiers, rows, table.getNotes());
}
/** Blank lines are how a textarea says "nothing here"; they are not notes. */
private static List<String> clean(List<String> bodies) {
return orEmpty(bodies).stream()
.map(body -> body == null ? "" : body.trim())
.filter(body -> !body.isEmpty())
.toList();
}
/** A missing JSON array and an empty one mean the same thing to an editor. */
private static <T> List<T> orEmpty(List<T> items) {
return items == null ? List.of() : items;
}
}
@@ -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);
}
}
+101
View File
@@ -0,0 +1,101 @@
package com.itsthevine.web;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.TextStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Service;
/**
* The opening times, read two ways: as the list on the page, and as the answer to "are they open now?"
*
* <p>Both used to be impossible. The hours were three lines of markup, so the only thing the site could do
* with them was print them — a visitor at half past two on a Sunday had to work out for themselves that
* the shop was shut. They are data now ({@link Shop#WEEK}), and this turns them into both.
*/
@Service
public class Hours {
/** One line of the printed list: "Tuesday Friday", "7:00am 2:00pm". Closed days say so. */
public record Span(String days, String hours, boolean closed) {}
/** "Open until 2:00pm", or "Closed · opens Tuesday at 7:00am". */
public record Status(boolean open, String summary) {}
/**
* The week, with consecutive days that keep the same times collapsed into one line — which is what a
* shop sign does, and what the three hand-written lines used to do by hand.
*/
public List<Span> week() {
List<Span> spans = new ArrayList<>();
int i = 0;
while (i < Shop.READING_ORDER.size()) {
DayOfWeek first = Shop.READING_ORDER.get(i);
Shop.Opening opening = Shop.WEEK.get(first);
int j = i;
while (j + 1 < Shop.READING_ORDER.size()
&& sameHours(opening, Shop.WEEK.get(Shop.READING_ORDER.get(j + 1)))) {
j++;
}
DayOfWeek last = Shop.READING_ORDER.get(j);
String days = first == last ? name(first) : name(first) + " " + name(last);
spans.add(opening == null
? new Span(days, "Closed", true)
: new Span(days, time(opening.opens()) + " " + time(opening.closes()), false));
i = j + 1;
}
return spans;
}
public Status now() {
return at(ZonedDateTime.now(Shop.ZONE));
}
/**
* @param when the moment to answer for; taken as an argument so a test can ask about a Sunday
* afternoon without waiting for one
*/
public Status at(ZonedDateTime when) {
LocalTime time = when.toLocalTime();
Shop.Opening today = Shop.WEEK.get(when.getDayOfWeek());
if (today != null && !time.isBefore(today.opens()) && time.isBefore(today.closes())) {
return new Status(true, "Open until " + time(today.closes()));
}
if (today != null && time.isBefore(today.opens())) {
return new Status(false, "Opens at " + time(today.opens()));
}
// Closed for the day, so the next answer is on a later day. Seven steps at most, and the shop is
// open five of them — the loop cannot run out.
for (int ahead = 1; ahead <= 7; ahead++) {
DayOfWeek day = when.getDayOfWeek().plus(ahead);
Shop.Opening next = Shop.WEEK.get(day);
if (next != null) {
String when_ = ahead == 1 ? "tomorrow" : name(day);
return new Status(false, "Closed · opens " + when_ + " at " + time(next.opens()));
}
}
return new Status(false, "Closed");
}
private static boolean sameHours(Shop.Opening a, Shop.Opening b) {
return a == null ? b == null : a.equals(b);
}
private static String name(DayOfWeek day) {
return day.getDisplayName(TextStyle.FULL, Locale.US);
}
/** "7:00am", "12:00pm" — the way the shop's own sign writes it, not "07:00". */
private static String time(LocalTime at) {
int hour = at.getHour() % 12 == 0 ? 12 : at.getHour() % 12;
return "%d:%02d%s".formatted(hour, at.getMinute(), at.getHour() < 12 ? "am" : "pm");
}
}
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -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,60 @@
package com.itsthevine.web;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
/**
* The shop itself: where it is, how to reach it, and when it is open.
*
* <p>These facts were prose in three templates. They are now stated once, in Java, because they are read
* by more than the eye: the footer and the contact page print them, the header works out from them
* whether the shop is open right now, and {@code StructuredData} hands the same opening times to a search
* engine — which is how a bakery ends up with its hours on the results page.
*
* <p>Hardcoded rather than a table with an admin screen, deliberately: opening times change about once a
* year, and a table would need a screen, a migration and a way to say "closed Christmas Eve" to be worth
* anything. When that day comes this is the one place to lift.
*/
public final class Shop {
/** The shop is in Princeville, Illinois; the container runs on UTC, which turns over first. */
public static final ZoneId ZONE = ZoneId.of("America/Chicago");
public static final String NAME = "The Vine Coffeehouse + Bakery";
public static final String STREET = "215 E Main Street";
public static final String CITY = "Princeville";
public static final String STATE = "IL";
public static final String POSTCODE = "61559";
/** For a tel: link. */
public static final String PHONE = "+13097010660";
/** For a human. */
public static final String PHONE_SPOKEN = "(309) 701-0660";
public static final String EMAIL = "[email protected]";
/**
* When the doors are open. Days that aren't here are closed days, which is what makes Sunday and
* Monday collapse into one line by themselves.
*/
public static final Map<DayOfWeek, Opening> WEEK = Map.of(
DayOfWeek.TUESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.WEDNESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.THURSDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.FRIDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
DayOfWeek.SATURDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(12, 0)));
/**
* The week as it is read, starting on the first open day rather than on Monday — the list on the page
* has always begun "Tuesday Friday", because that is when the shop's week begins.
*/
public static final List<DayOfWeek> READING_ORDER = List.of(
DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY,
DayOfWeek.SATURDAY, DayOfWeek.SUNDAY, DayOfWeek.MONDAY);
public record Opening(LocalTime opens, LocalTime closes) {}
private Shop() {
}
}
@@ -0,0 +1,156 @@
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.
*
* @param website the trap. It is a real field with a plausible name, hidden from anyone reading the
* page and skipped by the tab key — a person cannot fill it in, and the crawlers that
* post to every form they find fill in everything. A filled one is answered with the
* same thank-you a person gets: telling a bot it failed only teaches it to try again,
* and the enquiry is simply never recorded or sent.
*/
@PostMapping("/contact")
public String submit(@RequestParam String name,
@RequestParam String email,
@RequestParam String message,
@RequestParam(required = false) String website,
Model model) {
contactMeta(model);
if (website != null && !website.isBlank()) {
model.addAttribute("sent", true);
return "contact";
}
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";
}
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,121 @@
package com.itsthevine.web;
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 {
private final SitePhotos photos;
private final Hours hours;
private final StructuredData structuredData;
private final String baseUrl;
private final String build;
public SiteModel(SitePhotos photos, Hours hours, StructuredData structuredData,
@Value("${site.base-url:https://itsthevine.com}") String baseUrl,
@Value("${site.build:dev}") String build) {
this.photos = photos;
this.hours = hours;
this.structuredData = structuredData;
this.baseUrl = baseUrl.replaceAll("/+$", "");
this.build = build;
}
/** The opening times as the page prints them — see Hours#week. */
@ModelAttribute("hours")
public java.util.List<Hours.Span> hours() {
return hours.week();
}
/** Whether the shop is open at this moment, for the line in the header. */
@ModelAttribute("openNow")
public Hours.Status openNow() {
return hours.now();
}
// The shop's own facts, so no template states an address or a phone number itself. Four attributes
// rather than one object because a template reading `${shopStreet}` needs no explaining.
@ModelAttribute("shopStreet")
public String shopStreet() {
return Shop.STREET;
}
@ModelAttribute("shopTown")
public String shopTown() {
return Shop.CITY + ", " + Shop.STATE + " " + Shop.POSTCODE;
}
@ModelAttribute("shopPhone")
public String shopPhone() {
return Shop.PHONE;
}
@ModelAttribute("shopPhoneSpoken")
public String shopPhoneSpoken() {
return Shop.PHONE_SPOKEN;
}
@ModelAttribute("shopEmail")
public String shopEmail() {
return Shop.EMAIL;
}
/** schema.org JSON-LD for the head. Written by Jackson; see StructuredData. */
@ModelAttribute("structuredData")
public String structuredData() {
return structuredData.bakery();
}
/** What a shared link should show. */
@ModelAttribute("shareImage")
public String shareImage() {
return photos.of("gallery/Outside.webp");
}
/** 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.ZONE).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();
}
}
@@ -0,0 +1,92 @@
package com.itsthevine.web;
import java.time.DayOfWeek;
import java.time.format.TextStyle;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tools.jackson.databind.ObjectMapper;
/**
* The shop, described for machines: a schema.org Bakery in the page head.
*
* <p>This is how a search engine learns that itsthevine.com is a bakery in Princeville with a phone number
* and Saturday hours — the difference between a blue link and a listing that shows "Open ⋅ closes 2pm".
* For a shop whose customers find it by searching its town, that is worth more than anything else on this
* page.
*
* <p>Built from {@link Shop} and rendered once in the head, so the hours a crawler reads cannot drift from
* the ones a visitor reads. Serialised with Jackson rather than string-built: every value here is a
* constant of ours today, but the one day somebody interpolates a name into it, hand-built JSON in a
* {@code <script>} is an injection.
*/
@Service
public class StructuredData {
private final ObjectMapper json;
private final SitePhotos photos;
private final String baseUrl;
public StructuredData(ObjectMapper json, SitePhotos photos,
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
this.json = json;
this.photos = photos;
this.baseUrl = baseUrl.replaceAll("/+$", "");
}
public String bakery() {
Map<String, Object> shop = new LinkedHashMap<>();
shop.put("@context", "https://schema.org");
shop.put("@type", "Bakery");
shop.put("name", Shop.NAME);
shop.put("url", baseUrl);
shop.put("image", photos.of("gallery/Outside.webp"));
shop.put("telephone", Shop.PHONE);
shop.put("email", Shop.EMAIL);
// "$" rather than a number: schema.org wants a band, and ours is one coffee to a wedding cake.
shop.put("priceRange", "$");
shop.put("servesCuisine", List.of("Bakery", "Coffee", "Sandwiches"));
shop.put("address", Map.of(
"@type", "PostalAddress",
"streetAddress", Shop.STREET,
"addressLocality", Shop.CITY,
"addressRegion", Shop.STATE,
"postalCode", Shop.POSTCODE,
"addressCountry", "US"));
shop.put("openingHoursSpecification", openingHours());
// Jackson 3 (tools.jackson, what Boot 4 ships) throws unchecked, and there is nothing to catch
// anyway: the input is a map of our own constants.
return json.writeValueAsString(shop);
}
/**
* Days that keep the same times are given as one specification with several days on it, which is both
* shorter and what the vocabulary is for.
*/
private List<Map<String, Object>> openingHours() {
Map<Shop.Opening, List<String>> byHours = new TreeMap<>(
(a, b) -> a.opens().equals(b.opens()) ? a.closes().compareTo(b.closes()) : a.opens().compareTo(b.opens()));
for (DayOfWeek day : Shop.READING_ORDER) {
Shop.Opening opening = Shop.WEEK.get(day);
if (opening != null) {
byHours.computeIfAbsent(opening, o -> new java.util.ArrayList<>())
.add(day.getDisplayName(TextStyle.FULL, Locale.US));
}
}
return byHours.entrySet().stream()
.map(entry -> Map.<String, Object>of(
"@type", "OpeningHoursSpecification",
"dayOfWeek", entry.getValue(),
"opens", entry.getKey().opens().toString(),
"closes", entry.getKey().closes().toString()))
.toList();
}
}
@@ -0,0 +1,50 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* A footnote for the catering page as a whole — the terms that apply whichever table you were
* reading, like "we're happy to make changes, the price may change with them".
*
* <p>Its own table rather than a note on a package, because these outlive any one table: delete the
* wedding package and the page still has terms.
*/
@Entity
@Table(name = "catering_note")
public class CateringNote extends BaseEntity {
@Column(nullable = false, length = 600)
private String body;
@Column(name = "position", nullable = false)
private int position;
protected CateringNote() {
// for JPA
}
public CateringNote(String body, int position) {
say(body);
this.position = position;
}
public final void say(String body) {
this.body = Text.required(body, 600, "A note with nothing in it — delete it rather than blanking it.");
}
public void moveTo(int position) {
this.position = position;
}
public String getBody() {
return body;
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,10 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CateringNoteRepository extends JpaRepository<CateringNote, Long> {
List<CateringNote> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,214 @@
package com.itsthevine.web.domain;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One price table on the catering page — "Office", "Parties", "Weddings".
*
* <p>The bakery keeps these as a spreadsheet, so a spreadsheet is what this models: {@link
* CateringTier}s are the columns (a size, and what it costs) and {@link CateringRow}s are the lines
* (a baked good, and how much of it each column includes). A line holds one value per column, in
* column order.
*
* <p>That alignment is the whole reason this is an aggregate rather than three tables edited
* separately. A column and the values under it only mean anything together — drop the middle column
* on its own and every remaining value shifts one place left, so the Large box silently starts
* advertising the Medium box's contents at the Large price. Only this class can rearrange a table,
* and it will not accept an arrangement whose lines and columns disagree.
*/
@Entity
@Table(name = "catering_package")
public class CateringPackage extends BaseEntity {
/** Wider than this doesn't fit a phone, and these tables are read on phones. */
private static final int MOST_COLUMNS = 8;
private static final int MOST_LINES = 40;
private static final int MOST_NOTES = 12;
@Column(nullable = false, length = 120)
private String name;
@Column(length = 400)
private String blurb;
@Column(name = "position", nullable = false)
private int position;
/** The rules under this table: minimums, what can't be mixed, how delivery is charged. */
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "catering_package_note", joinColumns = @JoinColumn(name = "package_id"))
@OrderColumn(name = "position")
@Column(name = "body", nullable = false, length = 600)
private List<String> notes = new ArrayList<>();
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position")
private List<CateringTier> tiers = new ArrayList<>();
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position")
private List<CateringRow> rows = new ArrayList<>();
protected CateringPackage() {
// for JPA
}
public CateringPackage(String name, int position) {
describe(name, null);
this.position = position;
}
/**
* A column as the editor left it — its heading and its price. A null {@code id} is one they just
* added. (Named for the heading rather than the column so as not to shadow {@code @Column}.)
*/
public record Heading(Long id, String label, String price, String serves) {}
/** A line as the editor left it, with one value per column — blanks included. */
public record Line(Long id, String label, List<String> values) {}
public final void describe(String name, String blurb) {
this.name = Text.required(name, 120, "Please give the table a name, like \"Weddings\".");
String trimmed = Text.optional(blurb, 400);
this.blurb = trimmed.isEmpty() ? null : trimmed;
}
public void moveTo(int position) {
this.position = position;
}
public void replaceNotes(List<String> replacements) {
if (replacements.size() > MOST_NOTES) {
throw new IllegalArgumentException(
"That's a lot of small print — " + MOST_NOTES + " notes per table at most.");
}
List<String> cleaned = replacements.stream()
.map(note -> Text.required(note, 600, "One of the notes is empty — delete it rather than blanking it."))
.toList();
this.notes.clear();
this.notes.addAll(cleaned);
}
/**
* Make the table exactly this: these columns in this order, and these lines, each carrying one
* value per column.
*
* <p>The whole table arrives at once because that is the only way the editor can move a column and
* take its values with it. Anything they left out is deleted, anything carrying an id keeps its
* identity, and positions are renumbered from the order they arrived in rather than trusted from
* the request — so what's stored is what they were looking at when they hit save.
*/
public void arrange(List<Heading> columns, List<Line> lines) {
if (columns.size() > MOST_COLUMNS) {
throw new IllegalArgumentException(
"A table can have at most " + MOST_COLUMNS + " columns and still be readable on a phone.");
}
if (lines.size() > MOST_LINES) {
throw new IllegalArgumentException("A table can have at most " + MOST_LINES + " lines.");
}
for (Line line : lines) {
if (line.values().size() != columns.size()) {
// Not a message an editor should ever see: the screen sends whole tables. Worth saying
// out loud anyway, because the alternative is a table that quietly means something else.
throw new IllegalArgumentException("\"" + line.label() + "\" has " + line.values().size()
+ " entries but the table has " + columns.size()
+ " columns. Reload the page and try that again.");
}
}
List<CateringTier> arrangedTiers = new ArrayList<>();
int columnNumber = 1;
for (Heading column : columns) {
CateringTier tier = column.id() == null ? new CateringTier(this) : tier(column.id());
tier.describe(column.label(), column.price(), column.serves());
tier.moveTo(columnNumber++);
arrangedTiers.add(tier);
}
List<CateringRow> arrangedRows = new ArrayList<>();
int lineNumber = 1;
for (Line line : lines) {
CateringRow row = line.id() == null ? new CateringRow(this) : row(line.id());
row.describe(line.label());
row.replaceValues(line.values());
row.moveTo(lineNumber++);
arrangedRows.add(row);
}
// Both collections are rewritten only after every column and line has been accepted, so a
// rejected edit leaves the table exactly as it was.
settle(tiers, arrangedTiers, Comparator.comparingInt(CateringTier::getPosition));
settle(rows, arrangedRows, Comparator.comparingInt(CateringRow::getPosition));
}
/**
* Keep what was arranged, drop what wasn't. Removal from the collection is what deletes the row —
* these are {@code orphanRemoval} associations — so the omitted ones need no further handling.
*/
private static <T> void settle(List<T> stored, List<T> arranged, Comparator<T> byPosition) {
stored.removeIf(item -> !holds(arranged, item));
arranged.stream().filter(item -> !holds(stored, item)).forEach(stored::add);
stored.sort(byPosition);
}
/**
* Identity, deliberately: entities here inherit no {@code equals}, and two freshly built columns
* with the same heading are two different columns.
*/
private static boolean holds(List<?> items, Object item) {
return items.stream().anyMatch(candidate -> candidate == item);
}
private CateringTier tier(Long id) {
return tiers.stream().filter(t -> id.equals(t.getId())).findFirst().orElseThrow(this::changedUnderneath);
}
private CateringRow row(Long id) {
return rows.stream().filter(r -> id.equals(r.getId())).findFirst().orElseThrow(this::changedUnderneath);
}
private IllegalArgumentException changedUnderneath() {
return new IllegalArgumentException(
"Part of the " + name + " table isn't there any more. Reload the page to see it as it is now.");
}
public String getName() {
return name;
}
public String getBlurb() {
return blurb;
}
public int getPosition() {
return position;
}
public List<String> getNotes() {
return List.copyOf(notes);
}
public List<CateringTier> getTiers() {
return List.copyOf(tiers);
}
public List<CateringRow> getRows() {
return List.copyOf(rows);
}
}
@@ -0,0 +1,10 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CateringPackageRepository extends JpaRepository<CateringPackage, Long> {
List<CateringPackage> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,84 @@
package com.itsthevine.web.domain;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One line of a catering table: a baked good, and how much of it each column includes.
*
* <p>The values are positional — index 0 belongs to the first column — and there is always exactly
* one per column, blanks included. {@link CateringPackage} is the only thing that can set them,
* because it is the only thing that knows how many columns there are.
*/
@Entity
@Table(name = "catering_row")
public class CateringRow extends BaseEntity {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "package_id", nullable = false)
private CateringPackage cateringPackage;
@Column(nullable = false, length = 200)
private String label;
/**
* One entry per column, in column order. An empty string is a cell the bakery hasn't filled in —
* the spreadsheet has several — so blanks are stored rather than dropped, which is what keeps the
* list aligned with the columns.
*/
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "catering_row_value", joinColumns = @JoinColumn(name = "row_id"))
@OrderColumn(name = "position")
@Column(name = "value", nullable = false, length = 300)
private List<String> values = new ArrayList<>();
@Column(name = "position", nullable = false)
private int position;
protected CateringRow() {
// for JPA
}
CateringRow(CateringPackage cateringPackage) {
this.cateringPackage = cateringPackage;
}
void describe(String label) {
this.label = Text.required(label, 200, "Every line needs a name — what is it the customer gets?");
}
/** Replaced wholesale; the package has already checked there is one value per column. */
void replaceValues(List<String> replacements) {
List<String> cleaned = replacements.stream().map(value -> Text.optional(value, 300)).toList();
this.values.clear();
this.values.addAll(cleaned);
}
void moveTo(int position) {
this.position = position;
}
public String getLabel() {
return label;
}
public List<String> getValues() {
return List.copyOf(values);
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,91 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* One column of a catering table: a size, and what it costs.
*
* <p>Only {@link CateringPackage} can change one. A tier means nothing apart from the table it sits
* in — its price is read against the row values beside it — so the package is the only thing allowed
* to rearrange them, which is what keeps a row's values and its columns the same length.
*/
@Entity
@Table(name = "catering_tier")
public class CateringTier extends BaseEntity {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "package_id", nullable = false)
private CateringPackage cateringPackage;
@Column(nullable = false, length = 120)
private String label;
/** Whole cents, and nullable — see {@link Money}. */
@Column(name = "price_cents")
private Integer priceCents;
/**
* Roughly how many people this column feeds — "About 68 people".
*
* <p>Free text, and optional. A column named "Small" says nothing about how many it feeds, which is
* the question anyone ordering for an office actually has; a column named "125 people" has already
* answered it and leaves this blank rather than saying it twice.
*/
@Column(length = 80)
private String serves;
@Column(name = "position", nullable = false)
private int position;
protected CateringTier() {
// for JPA
}
CateringTier(CateringPackage cateringPackage) {
this.cateringPackage = cateringPackage;
}
/**
* @param price as the editor typed it; empty for a column that doesn't state one
* @param serves roughly how many people, or empty
*/
void describe(String label, String price, String serves) {
this.label = Text.required(label, 120,
"Every column needs a heading — a size like \"Large\", or who it feeds like \"1520 people\".");
this.priceCents = Money.cents(price);
String trimmed = Text.optional(serves, 80);
this.serves = trimmed.isEmpty() ? null : trimmed;
}
void moveTo(int position) {
this.position = position;
}
public String getLabel() {
return label;
}
public Integer getPriceCents() {
return priceCents;
}
/** What the column's price reads as — "$24", or null when it doesn't state one. */
public String getPrice() {
return Money.format(priceCents);
}
public String getServes() {
return serves;
}
public int getPosition() {
return position;
}
}
@@ -0,0 +1,72 @@
package com.itsthevine.web.domain;
import java.math.BigDecimal;
import java.util.Locale;
/**
* What a price is, in one place: how the bakery types one in, how it's stored, and how it's printed.
*
* <p>Editors type "24", "$24", "24.50" or nothing at all, so the parsing lives here rather than in the
* browser — a price the server didn't agree to is not a price, and {@code Number(text) * 100} gives
* 2410.0000000000005 for "24.10" on the way past. Stored as whole cents, because money is not a
* floating-point number.
*/
public final class Money {
/** Ten thousand dollars. Above this it's a decimal point in the wrong place, not a wedding. */
private static final int MOST_ANYTHING_COSTS = 1_000_000;
private Money() {
}
/**
* @param typed what the editor put in the box
* @return whole cents, or null for a column that doesn't state a price
*/
public static Integer cents(String typed) {
String cleaned = typed == null ? "" : typed.replace("$", "").replace(",", "").replace(" ", "").trim();
if (cleaned.isEmpty()) {
// Not an error: "ask us" is a legitimate thing for a column to say, and it says it by
// leaving the price empty.
return null;
}
BigDecimal amount;
try {
amount = new BigDecimal(cleaned);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("\"" + typed
+ "\" isn't a price. Leave it empty if that column doesn't have one.");
}
if (amount.scale() > 2) {
throw new IllegalArgumentException("Prices go to the cent — \"" + typed + "\" is finer than that.");
}
if (amount.signum() < 0) {
throw new IllegalArgumentException("A price can't be less than nothing.");
}
long asCents = amount.movePointRight(2).longValueExact();
if (asCents > MOST_ANYTHING_COSTS) {
throw new IllegalArgumentException("That price is over $10,000 — check the decimal point.");
}
return (int) asCents;
}
/**
* "$24", "$1,250", "$24.50" — never "$24.0", and null stays null.
*
* <p>{@code Locale.US} rather than the default: the price of a cake in Princeville, Illinois does
* not depend on which locale the container started in, and a default of de-DE would print
* "$1.250" for one thousand two hundred and fifty dollars.
*/
public static String format(Integer cents) {
if (cents == null) {
return null;
}
int dollars = cents / 100;
int change = cents % 100;
return change == 0
? String.format(Locale.US, "$%,d", dollars)
: String.format(Locale.US, "$%,d.%02d", dollars, change);
}
}
@@ -0,0 +1,37 @@
package com.itsthevine.web.domain;
/**
* Trimming and length rules for editor-supplied prose.
*
* <p>Every one of these limits is a column width, so the choice is between checking them here and
* letting Postgres reject the insert — which reaches the editor as an unexplained 500 with their
* afternoon's work still unsaved. The message names the offending text, because a catering table is
* a grid of thirty small cells and "too long" alone doesn't say which one.
*/
final class Text {
private Text() {
}
static String required(String value, int max, String missing) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(missing);
}
return capped(trimmed, max);
}
/** Trimmed, possibly empty — a blank cell is a real thing to want. */
static String optional(String value, int max) {
return capped(value == null ? "" : value.trim(), max);
}
private static String capped(String text, int max) {
if (text.length() > max) {
String preview = text.substring(0, Math.min(40, text.length()));
throw new IllegalArgumentException(
"\"" + preview + "\" is longer than the " + max + " characters that fit there.");
}
return text;
}
}
+9 -1
View File
@@ -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.
@@ -0,0 +1,141 @@
-- Goodie boxes and catering: the Office / Parties / Weddings price tables the bakery hands out.
--
-- These arrived as a spreadsheet, and a spreadsheet is what they are, so that is what this models:
-- a PACKAGE is one table on the page, its TIERS are the columns (what you get, for a price) and its
-- ROWS are the lines (which baked good, and how much of it in each column). A row therefore holds
-- one value per column, in column order — the two are kept in step in Java, because a table whose
-- lines and columns disagree quietly misprices what a customer is actually buying.
--
-- All of it is data rather than markup so the prices can move without a deploy. They will: the last
-- line of the spreadsheet says the tables are "mostly just an idea for people".
create table catering_package (
id bigserial primary key,
name varchar(120) not null,
-- Optional sentence under the heading. Nothing in the spreadsheet fills this in; it exists so
-- the bakery can explain a table without one of us editing a page.
blurb varchar(400),
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
-- Footnotes belonging to one table: the minimums, the flavor rules, the wedding delivery terms.
-- Kept as rows rather than one blob so each rule can be edited, reordered or dropped on its own.
create table catering_package_note (
package_id bigint not null references catering_package (id) on delete cascade,
position integer not null,
body varchar(600) not null,
primary key (package_id, position)
);
-- A column: the size, and what it costs. price_cents is nullable for a tier that is priced on
-- asking, and is cents rather than a formatted string so the app — not the browser — decides how
-- money is written.
create table catering_tier (
id bigserial primary key,
package_id bigint not null references catering_package (id) on delete cascade,
label varchar(120) not null,
price_cents integer,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index catering_tier_package_idx on catering_tier (package_id, position);
-- A line of the table: which baked good.
create table catering_row (
id bigserial primary key,
package_id bigint not null references catering_package (id) on delete cascade,
label varchar(200) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index catering_row_package_idx on catering_row (package_id, position);
-- One cell. `position` is the COLUMN — position 0 is the first tier, and so on — so a row always has
-- exactly as many values as its package has tiers. Empty strings are meaningful and expected: the
-- spreadsheet has lines that are named but not yet quantified.
create table catering_row_value (
row_id bigint not null references catering_row (id) on delete cascade,
position integer not null,
value varchar(300) not null,
primary key (row_id, position)
);
-- Footnotes for the page as a whole rather than any one table.
create table catering_note (
id bigserial primary key,
body varchar(600) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
-- Seeded from the bakery's own spreadsheet. Wording is theirs; the only changes are expanded
-- shorthand ("4 dz cc or sc" -> "4 dz cupcakes or sugar cookies") and fixed typos, because these
-- lines are read by customers. Everything here is editable in the admin.
insert into catering_package (id, name, position, created_at) values
(1, 'Office', 1, now()),
(2, 'Parties', 2, now()),
(3, 'Weddings', 3, now());
insert into catering_tier (id, package_id, label, price_cents, position, created_at) values
(1, 1, 'Small', 2400, 1, now()),
(2, 1, 'Medium', 3200, 2, now()),
(3, 1, 'Large', 4000, 3, now()),
(4, 2, '1520 people', 5400, 1, now()),
(5, 2, '2030 people', 7600, 2, now()),
(6, 2, '3040 people', 9800, 3, now()),
(7, 3, '125 people', 23600, 1, now()),
(8, 3, '200 people', 31000, 2, now()),
(9, 3, '250 people', 38600, 3, now());
insert into catering_row (id, package_id, label, position, created_at) values
( 1, 1, 'Mini muffins', 1, now()),
( 2, 1, 'Mini scones', 2, now()),
( 3, 1, 'Mini cinnamon rolls', 3, now()),
( 4, 2, 'Cake', 1, now()),
( 5, 2, 'Cupcakes', 2, now()),
( 6, 2, 'Sugar cookies', 3, now()),
( 7, 3, 'Bride & groom cake (8 in)', 1, now()),
( 8, 3, 'Sheet cakes', 2, now()),
( 9, 3, '12x17 bars', 3, now()),
(10, 3, 'Cupcakes', 4, now()),
(11, 3, 'Sugar cookies', 5, now());
insert into catering_row_value (row_id, position, value) values
( 1, 0, '12 items'), ( 1, 1, '18 items'), ( 1, 2, '24 items'),
( 2, 0, '6+6'), ( 2, 1, '6+6+6 or 12+6'), ( 2, 2, '6+6+6+6 or 12+6+6 or 12+12'),
-- Named in the spreadsheet but never quantified; the minimum below is the only rule it gives.
( 3, 0, ''), ( 3, 1, ''), ( 3, 2, ''),
( 4, 0, '6 in cake'), ( 4, 1, '8 in cake'), ( 4, 2, '10 in cake'),
( 5, 0, '1 dz sugar cookies or cupcakes'), ( 5, 1, '1.5 dz your choice'), ( 5, 2, '2 dz your choice'),
( 6, 0, ''), ( 6, 1, ''), ( 6, 2, ''),
( 7, 0, 'B&G cake'), ( 7, 1, 'B&G cake'), ( 7, 2, 'B&G cake'),
( 8, 0, '2 pans or a sheet cake'), ( 8, 1, '3 pans or a sheet cake'), ( 8, 2, '4 pans or a sheet cake'),
( 9, 0, '4 dz cupcakes or sugar cookies'),
( 9, 1, '5 dz cupcakes or sugar cookies'),
( 9, 2, '6 dz cupcakes or sugar cookies'),
(10, 0, ''), (10, 1, ''), (10, 2, ''),
(11, 0, ''), (11, 1, ''), (11, 2, '');
insert into catering_package_note (package_id, position, body) values
(1, 0, 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.'),
(2, 0, 'Add an extra dozen for $20.'),
(2, 1, 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.'),
-- Wedding-specific, so it sits with the wedding table rather than under the whole page.
(3, 0, 'The delivery fee depends on where the wedding is, and setup is charged separately.'),
(3, 1, 'We don''t provide serving materials, and we don''t set up decorations.');
insert into catering_note (id, body, position, created_at) values
(1, 'We''re happy to make changes — the price may change with them.', 1, now()),
(2, 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.', 2, now());
-- bigserial keeps its own counter; move it past the seeded ids so future inserts don't collide.
select setval('catering_package_id_seq', 3);
select setval('catering_tier_id_seq', 9);
select setval('catering_row_id_seq', 11);
select setval('catering_note_id_seq', 2);
@@ -0,0 +1,95 @@
-- The wedding table's lines, read the way the spreadsheet meant them.
--
-- V4 carried that table over literally, because the source is offset: the prices sit on the "cupcakes"
-- line, and "4 dz cc or sc" sits on the "12x17 bars" line. Taken at face value it produced five lines,
-- two of which ("Cupcakes", "Sugar cookies") had no quantity in any column and rendered as a row of
-- dashes on the page.
--
-- Reading it as a baker would: a 12x17 pan IS the sheet pan, so "Sheet cakes" and "12x17 bars" are one
-- offering, and "cc or sc" is cupcakes-or-sugar-cookies, which is what the 4/5/6 dozen counts belong to.
-- That gives three lines that each say something:
--
-- Bride & groom cake (8 in) B&G cake B&G cake B&G cake
-- Sheet cakes or 12x17 bars 2 pans 3 pans 4 pans
-- Cupcakes or sugar cookies 4 dz 5 dz 6 dz
--
-- This is an interpretation of somebody else's shorthand, so every statement below is guarded on the
-- text V4 wrote. If the bakery has already edited these lines in the admin, this migration finds nothing
-- to change and leaves their wording alone — which matters, because by the time this runs the tables are
-- live and theirs to edit.
--
-- V4 is deliberately NOT edited: it has already been applied, and Flyway validates checksums.
-- "Sheet cakes" absorbs the pan size, and its cells drop the "or a sheet cake" the label now carries.
update catering_row r
set label = 'Sheet cakes or 12x17 bars'
from catering_package p
where p.id = r.package_id
and p.name = 'Weddings'
and r.label = 'Sheet cakes';
update catering_row_value v
set value = '2 pans'
from catering_row r join catering_package p on p.id = r.package_id
where r.id = v.row_id and p.name = 'Weddings'
and r.label = 'Sheet cakes or 12x17 bars'
and v.position = 0 and v.value = '2 pans or a sheet cake';
update catering_row_value v
set value = '3 pans'
from catering_row r join catering_package p on p.id = r.package_id
where r.id = v.row_id and p.name = 'Weddings'
and r.label = 'Sheet cakes or 12x17 bars'
and v.position = 1 and v.value = '3 pans or a sheet cake';
update catering_row_value v
set value = '4 pans'
from catering_row r join catering_package p on p.id = r.package_id
where r.id = v.row_id and p.name = 'Weddings'
and r.label = 'Sheet cakes or 12x17 bars'
and v.position = 2 and v.value = '4 pans or a sheet cake';
-- The dozens were never about the bars; they are the cupcakes-or-sugar-cookies count. The line takes
-- that name, and the two empty lines below it are what it replaces.
update catering_row r
set label = 'Cupcakes or sugar cookies'
from catering_package p
where p.id = r.package_id
and p.name = 'Weddings'
and r.label = '12x17 bars';
update catering_row_value v
set value = '4 dz'
from catering_row r join catering_package p on p.id = r.package_id
where r.id = v.row_id and p.name = 'Weddings'
and r.label = 'Cupcakes or sugar cookies'
and v.position = 0 and v.value = '4 dz cupcakes or sugar cookies';
update catering_row_value v
set value = '5 dz'
from catering_row r join catering_package p on p.id = r.package_id
where r.id = v.row_id and p.name = 'Weddings'
and r.label = 'Cupcakes or sugar cookies'
and v.position = 1 and v.value = '5 dz cupcakes or sugar cookies';
update catering_row_value v
set value = '6 dz'
from catering_row r join catering_package p on p.id = r.package_id
where r.id = v.row_id and p.name = 'Weddings'
and r.label = 'Cupcakes or sugar cookies'
and v.position = 2 and v.value = '6 dz cupcakes or sugar cookies';
-- Only the wedding table's copies: "Cupcakes" is a real, quantified line in the parties table, which the
-- join on the package name protects. Their cells go with them (catering_row_value cascades).
delete from catering_row r
using catering_package p
where p.id = r.package_id
and p.name = 'Weddings'
and r.label in ('Cupcakes', 'Sugar cookies')
and not exists (
select 1 from catering_row_value v
where v.row_id = r.id and v.value <> ''
);
-- Positions 1, 2, 3 survive the deletion untouched (the removed lines were 4 and 5), so there is nothing
-- to renumber. Stated rather than assumed: the page reads the order from these.
@@ -0,0 +1,126 @@
-- The catering tables in the bakery's voice instead of the spreadsheet's shorthand.
--
-- V4 kept the source wording deliberately: it was a spreadsheet written for the people who bake from it,
-- and inventing copy for somebody else's prices is not a migration's job. But the page is read by
-- customers, and "B&G cake", "2 pans", "4 dz" and "6+6" are notes-to-self, not an offer. So this rewrites
-- the cells as sentences, fills in the blurb each table always had room for, and merges the parties
-- lines the same way V5 merged the wedding ones — "Cupcakes" quantified as "1 dz sugar cookies or
-- cupcakes" with an empty "Sugar cookies" line beneath it is the same offset, and the same fix.
--
-- Guarded on the exact text V4 and V5 left, as V5 was: anything the bakery has already reworded in the
-- admin is left exactly as they wrote it. Their wording wins over mine.
-- --- names and the line under each heading ----------------------------------------------------------
update catering_package set name = 'Office boxes'
where name = 'Office';
update catering_package set blurb = 'Mini pastries for a morning meeting, boxed and ready to collect.'
where name = 'Office boxes' and blurb is null;
update catering_package set blurb = 'A cake, and something to hand round, sized to your guest list.'
where name = 'Parties' and blurb is null;
update catering_package set blurb = 'A cake for the couple, and dessert for everyone else.'
where name = 'Weddings' and blurb is null;
-- --- office boxes -----------------------------------------------------------------------------------
update catering_row_value v set value = 'A dozen'
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '12 items';
update catering_row_value v set value = 'Eighteen'
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '18 items';
update catering_row_value v set value = 'Two dozen'
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '24 items';
-- "6+6" is six of one flavour and six of another, which is worth saying out loud.
update catering_row_value v set value = 'A dozen, in two flavors'
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6';
update catering_row_value v set value = 'Eighteen, in two or three flavors'
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6+6 or 12+6';
update catering_row_value v set value = 'Two dozen, in up to four flavors'
from catering_row r where r.id = v.row_id and r.label = 'Mini scones'
and v.value = '6+6+6+6 or 12+6+6 or 12+12';
update catering_package_note set body = 'Everything is baked in sixes, so each item comes in multiples of six.'
where body = 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.';
insert into catering_package_note (package_id, position, body)
select p.id, 1, 'Mixing flavors within one item needs a larger order — ask us and we will tell you.'
from catering_package p
where p.name = 'Office boxes'
and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 1);
-- --- parties ----------------------------------------------------------------------------------------
update catering_row_value v set value = 'A 6-inch cake'
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '6 in cake';
update catering_row_value v set value = 'An 8-inch cake'
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '8 in cake';
update catering_row_value v set value = 'A 10-inch cake'
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '10 in cake';
-- Same offset as the wedding table: the quantities on the "Cupcakes" line were always
-- cupcakes-or-sugar-cookies, and the "Sugar cookies" line below carried nothing at all.
update catering_row r set label = 'Cupcakes or sugar cookies'
from catering_package p
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Cupcakes';
update catering_row_value v set value = 'A dozen, either one'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
and v.value = '1 dz sugar cookies or cupcakes';
update catering_row_value v set value = 'Eighteen, your choice'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
and v.value = '1.5 dz your choice';
update catering_row_value v set value = 'Two dozen, your choice'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
and v.value = '2 dz your choice';
delete from catering_row r
using catering_package p
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Sugar cookies'
and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.value <> '');
update catering_package_note set body = 'An extra dozen is $20.'
where body = 'Add an extra dozen for $20.';
update catering_package_note
set body = 'Cake and cupcake flavors can be different once you are ordering a dozen cupcakes or more.'
where body = 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.';
-- --- weddings ---------------------------------------------------------------------------------------
-- The label said it and the cell repeated it. The cell now carries the size instead.
update catering_row r set label = 'Bride & groom cake'
from catering_package p
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Bride & groom cake (8 in)';
update catering_row_value v set value = 'Eight inch'
from catering_row r where r.id = v.row_id and r.label = 'Bride & groom cake' and v.value = 'B&G cake';
update catering_row r set label = 'Sheet cakes or 12×17 bars'
from catering_package p
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Sheet cakes or 12x17 bars';
update catering_row_value v set value = 'Two pans'
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '2 pans';
update catering_row_value v set value = 'Three pans'
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '3 pans';
update catering_row_value v set value = 'Four pans'
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '4 pans';
update catering_row_value v set value = 'Four dozen'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '4 dz';
update catering_row_value v set value = 'Five dozen'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '5 dz';
update catering_row_value v set value = 'Six dozen'
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '6 dz';
-- --- the page's own terms ---------------------------------------------------------------------------
update catering_note
set body = 'These are a guide rather than a menu. We are happy to make changes, though the price may change with them.'
where body = 'We''re happy to make changes — the price may change with them.';
update catering_note
set body = 'If we can''t do something, we will tell you.'
where body = 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.';
@@ -0,0 +1,66 @@
-- Two things the spreadsheet never said out loud, and the page was getting wrong.
--
-- 1. AN OFFICE BOX IS A TOTAL, NOT AN ASSORTMENT. A Small box is twelve items, mixed in sixes from the
-- minis — not twelve muffins AND twelve scones AND some cinnamon rolls, which is what three ticked
-- lines on a card claimed. The bakery confirmed it: mix them, in sixes, to the size's item count.
--
-- The fix keeps the shape everything else uses rather than inventing a mode for this one table: the
-- alternative goes INTO the line, the way "Cupcakes or sugar cookies" already does in the parties and
-- wedding tables. One line, "Any mix of…", and the size's cell carries the count. So the rule for the
-- whole page is now sayable in one sentence: lines are things you get (and), and a choice within a
-- line is written into its name (or).
--
-- 2. "SMALL" DOESN'T TELL ANYONE HOW MANY PEOPLE IT FEEDS. The party and wedding tables size themselves
-- by head count; the office table sizes itself by box. `serves` gives every column somewhere to say
-- it, and the admin gets a box for it. The office numbers below are estimates from the item counts and
-- are meant to be corrected by the people who pack the boxes.
alter table catering_tier add column serves varchar(80);
-- --- 1. the office box's contents ---------------------------------------------------------------------
update catering_row r
set label = 'Any mix of mini muffins, mini scones and mini cinnamon rolls'
from catering_package p
where p.id = r.package_id
and p.name = 'Office boxes'
and r.label = 'Mini muffins';
update catering_row_value v set value = '12 items'
from catering_row r
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 0 and v.value = 'A dozen';
update catering_row_value v set value = '18 items'
from catering_row r
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 1 and v.value = 'Eighteen';
update catering_row_value v set value = '24 items'
from catering_row r
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 2 and v.value = 'Two dozen';
-- The other two lines are now named inside the first one. Guarded on the wording V6 left, so a line the
-- bakery has since edited stays and can be tidied by hand.
delete from catering_row r
using catering_package p
where p.id = r.package_id
and p.name = 'Office boxes'
and r.label in ('Mini scones', 'Mini cinnamon rolls');
-- "6+6" was the spreadsheet showing how a dozen scones splits by flavour. With one mixed line, that
-- belongs in the note, where the bakery's own minimum already lives.
update catering_package_note
set body = 'Baked in sixes, so a dozen can be six muffins and six scones.'
where body = 'Everything is baked in sixes, so each item comes in multiples of six.';
-- --- 2. how many people each column feeds -------------------------------------------------------------
update catering_tier t set serves = 'About 68 people'
from catering_package p
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Small' and t.serves is null;
update catering_tier t set serves = 'About 1012 people'
from catering_package p
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Medium' and t.serves is null;
update catering_tier t set serves = 'About 1520 people'
from catering_package p
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Large' and t.serves is null;
-- The party and wedding columns are already named by head count ("1520 people", "125 people"), so a
-- `serves` line under the price would say it twice. Left null on purpose.
Binary file not shown.
Binary file not shown.

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

+141
View File
@@ -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-11 w-11 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,171 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{admin/layout :: page('The catalogue', ~{::content})}">
<body>
<div th:fragment="content">
<!--/*
One form per row, not one per button.
Every control in a row submits that row's form, and `name="do"` says which was pressed — `move:-1`,
`photo:<key>:0`, `delete`. Forty items with a form per control came to 467 forms and 464 CSRF tokens,
which is 317 KB of HTML for a screen that gets opened on a phone in a bakery; this is 89 forms.
Only `save` reads the text boxes, so moving a row never saves a half-typed name. Those buttons also
carry `formnovalidate`: the name box is `required`, and without it a browser would refuse to submit
"move down" while the box was empty — which is nothing to do with moving the row.
*/-->
<section class="card">
<h2 class="card-heading">Categories</h2>
<p class="mt-1 text-sm text-bakery-600">
These are the filter buttons on the products page, in this order. Renaming one moves everything
filed under it too.
</p>
<ul class="mt-3 divide-y divide-bakery-100">
<li th:each="filter, f : ${filters}">
<form method="post" th:action="@{/admin/categories/{id}(id=${filter.id})}"
class="flex flex-wrap items-center gap-2 py-2">
<div class="flex gap-1">
<button type="submit" name="do" value="move:-1" formnovalidate class="btn-icon" th:disabled="${f.first}"
th:aria-label="|Move ${filter.name} up|">&uarr;</button>
<button type="submit" name="do" value="move:1" formnovalidate class="btn-icon" th:disabled="${f.last}"
th:aria-label="|Move ${filter.name} down|">&darr;</button>
</div>
<label class="flex-1 min-w-52">
<span class="sr-only" th:text="|Name of the ${filter.name} category|">Name</span>
<input class="field" name="name" th:value="${filter.name}" required>
</label>
<button type="submit" name="do" value="save" class="btn-secondary">Rename</button>
<span class="text-sm text-bakery-500 whitespace-nowrap"
th:text="|${filter.used} item${filter.used == 1 ? '' : 's'}|">0 items</span>
<button type="submit" name="do" value="delete" formnovalidate class="btn-danger" th:disabled="${filter.used > 0}"
th:title="${filter.used > 0} ? 'Move its items somewhere else first' : 'Delete'"
th:aria-label="|Delete the ${filter.name} category|">Delete</button>
</form>
</li>
</ul>
<form method="post" action="/admin/categories" class="mt-3 flex gap-2">
<label class="flex-1">
<span class="sr-only">New category</span>
<input class="field" name="name" placeholder="New category" required>
</label>
<button type="submit" class="btn-primary">Add</button>
</form>
</section>
<!--/* Adding an item. enctype matters: without it the browser posts filenames instead of files. */-->
<section class="card">
<h2 class="card-heading">Add something new</h2>
<p class="mt-1 text-sm text-bakery-600">
New items go to the top of the products page. Photos are resized, stripped of their EXIF (including
the location your phone put in them) and converted on upload, so this can take a few seconds each.
</p>
<form method="post" action="/admin/items" enctype="multipart/form-data" class="mt-3 space-y-3">
<div class="grid gap-2 sm:grid-cols-[1fr_12rem]">
<label class="block">
<span class="sr-only">What is it?</span>
<input class="field" name="name" placeholder="What is it? e.g. Chocolate drip cake" required>
</label>
<label class="block">
<span class="sr-only">Category</span>
<select class="field" name="category" required>
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}">Cakes</option>
</select>
</label>
</div>
<div class="flex flex-wrap items-center gap-2">
<input type="file" name="photos" accept="image/*" multiple required
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
<button type="submit" class="btn-primary">Add to the page</button>
</div>
</form>
</section>
<section>
<h2 class="card-heading" th:text="|On the page (${#lists.size(items)})|">On the page</h2>
<ul class="mt-3 space-y-3">
<li th:each="item, i : ${items}" class="card">
<form method="post" th:action="@{/admin/items/{id}(id=${item.id})}"
class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="flex sm:flex-col gap-1 sm:pt-1">
<button type="submit" name="do" value="move:-1" formnovalidate class="btn-icon" th:disabled="${i.first}"
th:aria-label="|Move ${item.name} up|">&uarr;</button>
<button type="submit" name="do" value="move:1" formnovalidate class="btn-icon" th:disabled="${i.last}"
th:aria-label="|Move ${item.name} down|">&darr;</button>
</div>
<div class="flex-1 min-w-0 space-y-3">
<div class="grid gap-2 sm:grid-cols-[1fr_12rem_auto]">
<label class="block">
<span class="sr-only">Name</span>
<input class="field" name="name" th:value="${item.name}" required>
</label>
<label class="block">
<span class="sr-only">Category</span>
<select class="field" name="category">
<!--/* An item can sit in a category nobody defined; don't silently retype it. */-->
<option th:if="${!#lists.contains(filters.![name], item.category)}"
th:value="${item.category}" th:text="${item.category}" selected>Uncategorised</option>
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}"
th:selected="${filter.name == item.category}">Cakes</option>
</select>
</label>
<button type="submit" name="do" value="save" class="btn-secondary">Save</button>
</div>
<!--/* Photos, in the order the products page shows them: the first is the one the card leads
with. Left/right rather than a drag target, which is far easier to hit on a phone. */-->
<div class="flex flex-wrap gap-3">
<figure th:each="photo, p : ${item.photos}" class="w-28">
<img th:src="${photo.url}" alt="" loading="lazy"
class="w-28 h-28 rounded-field object-cover border border-bakery-200 bg-bakery-100">
<figcaption class="mt-1 flex items-center justify-between gap-1">
<div class="flex gap-1">
<button type="submit" name="do" th:value="|photo:${photo.key}:-1|" formnovalidate class="btn-icon"
th:disabled="${p.first}" aria-label="Move photo earlier">&larr;</button>
<button type="submit" name="do" th:value="|photo:${photo.key}:1|" formnovalidate class="btn-icon"
th:disabled="${p.last}" aria-label="Move photo later">&rarr;</button>
</div>
<!--/* The server refuses to leave an item with no photos; saying so up front beats an
error message. */-->
<button type="submit" name="do" th:value="|photo:${photo.key}:0|" formnovalidate class="btn-icon"
th:disabled="${#lists.size(item.photos) == 1}"
th:title="${#lists.size(item.photos) == 1} ? 'An item needs at least one photo' : 'Remove photo'"
aria-label="Remove photo">&times;</button>
</figcaption>
</figure>
</div>
<div class="flex flex-wrap items-center gap-2">
<button type="submit" name="do" value="delete" formnovalidate class="btn-danger ml-auto"
th:aria-label="|Remove ${item.name} from the products page|">Delete item</button>
</div>
</div>
</form>
<!--/* Its own form, because a file input needs a multipart encoding and the rest of the row
doesn't. */-->
<form method="post" th:action="@{/admin/items/{id}/photos(id=${item.id})}"
enctype="multipart/form-data" class="mt-2 flex flex-wrap items-center gap-2 sm:pl-12">
<input type="file" name="photos" accept="image/*" multiple required
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
<button type="submit" class="btn-secondary">Add photos</button>
</form>
</li>
</ul>
<p th:if="${#lists.isEmpty(items)}" class="mt-3 text-bakery-600">
Nothing here yet — add something above.
</p>
</section>
</div>
</body>
</html>
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{admin/layout :: page('Goodie boxes & catering', ~{::content})}">
<body>
<div th:fragment="content">
<section>
<h2 class="card-heading">The price tables</h2>
<p class="mt-1 text-sm text-bakery-600">
In the order they appear on the page. Open one to change its columns, its prices or what's in it.
A table with no columns or no lines stays off the public page until it has both.
</p>
<ul class="mt-3 space-y-3">
<li th:each="table, t : ${menu.packages}" class="card flex flex-wrap items-center gap-3">
<div class="flex gap-1">
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
<input type="hidden" name="by" value="-1">
<button type="submit" class="btn-icon" th:disabled="${t.first}"
th:aria-label="|Move the ${table.name} table up|">&uarr;</button>
</form>
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
<input type="hidden" name="by" value="1">
<button type="submit" class="btn-icon" th:disabled="${t.last}"
th:aria-label="|Move the ${table.name} table down|">&darr;</button>
</form>
</div>
<div class="flex-1 min-w-60">
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}"
class="link h4 text-bakery-900"
th:text="${table.name}">Office</a>
<p class="text-sm text-bakery-600">
<span th:text="|${#lists.size(table.tiers)} column${#lists.size(table.tiers) == 1 ? '' : 's'}|">3 columns</span>,
<span th:text="|${#lists.size(table.rows)} line${#lists.size(table.rows) == 1 ? '' : 's'}|">3 lines</span>
<span th:if="${#lists.isEmpty(table.tiers) or #lists.isEmpty(table.rows)}"
class="text-bakery-700"> — not on the page yet</span>
</p>
</div>
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}" class="btn-secondary">Edit</a>
<form method="post" th:action="@{/admin/catering/tables/{id}/delete(id=${table.id})}">
<button type="submit" class="btn-danger"
th:aria-label="|Delete the ${table.name} table|">Delete</button>
</form>
</li>
</ul>
<p th:if="${#lists.isEmpty(menu.packages)}" class="mt-3 text-bakery-600">
No tables yet. The catering page will tell people to call instead until there is one.
</p>
<form method="post" action="/admin/catering/tables" class="mt-4 flex gap-2">
<label class="flex-1">
<span class="sr-only">New table</span>
<input class="field" name="name" placeholder="New table, e.g. Graduation parties" required>
</label>
<button type="submit" class="btn-primary">Add</button>
</form>
</section>
<!--/* The page's own terms, as opposed to the small print under one table. Replaced as a whole list:
deleting one is an omission, which is the same rule the tables follow. */-->
<section class="card">
<h2 class="card-heading">Under the whole page</h2>
<p class="mt-1 text-sm text-bakery-600">Terms that apply whichever table someone is reading.</p>
<form method="post" action="/admin/catering/notes" class="mt-3 space-y-2">
<label th:each="note : ${menu.notes}" class="block">
<span class="sr-only">Note</span>
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" th:text="${note}"></textarea>
</label>
<!--/* An empty box is how you delete one: blank notes are dropped on save. */-->
<label class="block">
<span class="sr-only">Another note</span>
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" placeholder="Add another note"></textarea>
</label>
<button type="submit" class="btn-primary">Save these notes</button>
<p class="text-sm text-bakery-600">Clearing a box and saving removes that note.</p>
</form>
</section>
</div>
</body>
</html>
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<!--/*
The admin's shell.
It deliberately doesn't wear the site's chrome: the public nav would offer an editor links away from
what they were doing, and the opening hours in the footer are noise on a screen whose whole job is the
catalogue. Same stylesheet, same brand.
Getting here at all means signing in — /admin/** is an authenticated path, so an unknown visitor is
sent to Authentik before any of this renders.
*/-->
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="page(title, content)">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<link rel="icon" href="/images/resources/logo_L.png">
<title th:text="|${title} · The Vine|">The Vine — admin</title>
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
</head>
<body>
<div class="min-h-screen bg-bakery-50">
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<header class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="font-lejour text-4xl text-bakery-700">The Vine</h1>
<nav class="mt-1 flex flex-wrap gap-4 text-sm">
<a href="/admin" class="link">The catalogue</a>
<a href="/admin/catering" class="link">Goodie boxes &amp; catering</a>
</nav>
</div>
<div class="flex items-center gap-2">
<a href="/products" class="btn-secondary">View the site</a>
<!--/* A real form post: the platform's logout expects one, and it also ends the Authentik
session — a link would leave you signed in at the identity provider and straight back in
on the next click. */-->
<form method="post" action="/logout">
<button type="submit" class="btn-secondary">Sign out</button>
</form>
</div>
</header>
<!--/* One place for the outcome of the last edit, whichever page posted it. */-->
<div th:if="${problem}" role="alert"
class="mt-6 rounded-field border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
th:text="${problem}">Something did not save.</div>
<div th:if="${done}" role="status"
class="mt-6 rounded-field border border-bakery-200 bg-white px-4 py-3 text-sm text-bakery-800"
th:text="${done}">Saved.</div>
<div class="mt-6 space-y-6">
<div th:replace="${content}"></div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{admin/layout :: page(${table.name} + ' table', ~{::content})}">
<body>
<div th:fragment="content">
<!--/*
One price table, in one form.
Every button in here submits this form. `name="do"` says which was pressed, and its value carries the
position it applies to (`remove-column:2`, `move-line:0:1`) — a button can only send its own name and
value, so that is where the argument goes.
Only "Save" writes anything. The structural buttons come back with the table you were looking at, plus
or minus a column or a line, and every cell you had typed still in it: adding a column adds an empty
entry to every line, and removing one takes its entries with it, so the grid stays square. That
alignment is the invariant CateringPackage#arrange refuses to break, and doing it on the server means
there is one implementation of it rather than one here and one in a browser.
*/-->
<form method="post" th:action="@{/admin/catering/tables/{id}(id=${tableId})}" th:object="${table}">
<div class="card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h2 class="card-heading">This table</h2>
<a href="/admin/catering" class="link text-sm">All tables</a>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-[14rem_1fr]">
<label class="block">
<span class="sr-only">Table name</span>
<input class="field" th:field="*{name}" placeholder="Weddings" required>
</label>
<label class="block">
<span class="sr-only">A line under the heading</span>
<input class="field" th:field="*{blurb}" placeholder="Optional — a line under the heading">
</label>
</div>
<!--/* Wide tables scroll here rather than making the page scroll sideways. */-->
<div class="mt-4 -mx-4 overflow-x-auto px-4">
<table class="w-full border-separate border-spacing-1">
<thead>
<tr>
<th scope="col" class="w-48 text-left text-sm font-medium text-bakery-600">What they get</th>
<th th:each="column, c : *{columns}" scope="col" class="min-w-44 align-top">
<input type="hidden" th:field="*{columns[__${c.index}__].id}">
<input class="field" th:field="*{columns[__${c.index}__].label}" placeholder="Small"
th:aria-label="|Heading for column ${c.count}|">
<input class="field mt-1" th:field="*{columns[__${c.index}__].price}"
placeholder="$24 — leave empty to ask" th:aria-label="|Price for column ${c.count}|">
<!--/* Optional: only worth filling in when the heading isn't already a head count. */-->
<input class="field mt-1" th:field="*{columns[__${c.index}__].serves}"
placeholder="Serves — optional" th:aria-label="|How many people column ${c.count} feeds|">
<div class="mt-1 flex justify-center gap-1">
<button type="submit" name="do" th:value="|move-column:${c.index}:-1|" class="btn-icon"
th:disabled="${c.first}" aria-label="Move this column left">&larr;</button>
<button type="submit" name="do" th:value="|move-column:${c.index}:1|" class="btn-icon"
th:disabled="${c.last}" aria-label="Move this column right">&rarr;</button>
<button type="submit" name="do" th:value="|remove-column:${c.index}|" class="btn-icon"
title="Removes this column and its entries on every line"
aria-label="Remove this column">&times;</button>
</div>
</th>
<th scope="col" class="w-28 align-top">
<button type="submit" name="do" value="add-column" class="btn-secondary">+ Column</button>
</th>
</tr>
</thead>
<tbody>
<tr th:each="line, l : *{lines}">
<th scope="row" class="text-left align-top">
<input type="hidden" th:field="*{lines[__${l.index}__].id}">
<input class="field" th:field="*{lines[__${l.index}__].label}" placeholder="Mini muffins"
th:aria-label="|Name of line ${l.count}|">
</th>
<td th:each="value, v : ${line.values}" class="align-top">
<input class="field" th:field="*{lines[__${l.index}__].values[__${v.index}__]}" placeholder="—"
th:aria-label="|Line ${l.count}, column ${v.count}|">
</td>
<td class="align-top">
<div class="flex gap-1">
<button type="submit" name="do" th:value="|move-line:${l.index}:-1|" class="btn-icon"
th:disabled="${l.first}" aria-label="Move this line up">&uarr;</button>
<button type="submit" name="do" th:value="|move-line:${l.index}:1|" class="btn-icon"
th:disabled="${l.last}" aria-label="Move this line down">&darr;</button>
<button type="submit" name="do" th:value="|remove-line:${l.index}|" class="btn-icon"
aria-label="Remove this line">&times;</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<button type="submit" name="do" value="add-line" class="btn-secondary mt-1">+ Line</button>
<div class="mt-4">
<p class="text-sm text-bakery-600">
Small print under this table — minimums, what can't be mixed, how delivery is charged.
</p>
<div class="mt-2 space-y-2">
<div th:each="note, n : *{notes}" class="flex items-start gap-2">
<label class="flex-1">
<span class="sr-only" th:text="|Note ${n.count}|">Note</span>
<textarea class="field min-h-[3.25rem]" rows="2" th:field="*{notes[__${n.index}__]}"></textarea>
</label>
<button type="submit" name="do" th:value="|remove-note:${n.index}|" class="btn-icon mt-1"
aria-label="Remove this note">&times;</button>
</div>
</div>
<button type="submit" name="do" value="add-note" class="btn-secondary mt-2">+ Note</button>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-bakery-100 pt-3">
<button type="submit" name="do" value="save" class="btn-primary">Save this table</button>
<a th:href="@{/admin/catering/tables/{id}(id=${tableId})}" class="btn-secondary">Start again</a>
<span th:if="${unsaved}" class="text-sm text-bakery-700">
Not saved yet — press Save when the table looks right.
</span>
</div>
</div>
</form>
</div>
</body>
</html>
+146
View File
@@ -0,0 +1,146 @@
<!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">
<div th:replace="~{fragments/opening :: photographed(
'Order ahead',
'Goodie boxes &amp; catering',
'Boxes for the office, packages for a party, and cakes and desserts for a wedding.',
'gallery/Cakes.webp')}"></div>
<div class="bg-bakery-50">
<div class="container pt-14 pb-16 md:pt-16 md:pb-24">
<p class="measure text-center text-bakery-800 leading-relaxed">
Every one of these is a starting point. Tell us the date, the number of people and what you had in
mind, and we will bake to it.
</p>
<div class="mt-10" th:replace="~{fragments/opening :: divider}"></div>
<div class="mt-12 max-w-6xl mx-auto space-y-16 md:space-y-20">
<!--/*
One block per table, and each size within it is a card: the price, then what you get for it.
This replaced a real <table>. A table is the honest shape for a price matrix, and it is how the
bakery keeps these — but it is also exactly what the source spreadsheet looks like, and reading a
spreadsheet back to a customer is not an offer. Cards say "here is what you get" three times over
instead of asking someone to find the cell where a row meets a column, and they need no separate
phone rendering, because a stack of cards IS the phone rendering.
Each card walks the same rows in the same order and takes the cell for its own column — the
alignment the aggregate guarantees.
*/-->
<section th:each="table : ${menu.packages}">
<div th:replace="~{fragments/opening :: section-head(${table.name}, ${table.blurb})}"></div>
<!--/* Three sizes is the common case; a table with one or two shouldn't stretch to fill the row. */-->
<!--/*
Three sizes go three-up from md, not lg like the shared `card-grid`: a grid of PHOTOS is fine
two-wide, but three prices are a comparison, and landing 2 + 1 on a laptop strands the third
size on its own row where it reads as an afterthought.
The column count is entirely in the classappend rather than overriding `card-grid`, and that
is not a style preference: Tailwind emits grid-cols-1, -2, -3 in that order, so `grid-cols-3`
from the shared class would beat a `grid-cols-2` written next to it in the markup. Two classes
for one property, with the winner decided by the stylesheet's order — the same trap that made
`hidden` fail to hide the open-now line. Never both.
*/-->
<div class="grid gap-6 md:gap-8 mt-8"
th:classappend="${#lists.size(table.tiers) == 1} ? 'max-w-sm mx-auto'
: (${#lists.size(table.tiers) == 2} ? 'sm:grid-cols-2 max-w-3xl mx-auto'
: 'sm:grid-cols-2 md:grid-cols-3')">
<article th:each="tier, t : ${table.tiers}"
class="relative flex flex-col overflow-hidden panel">
<!--/* The wordmark's branch, at watermark weight. */-->
<span class="panel-mark -top-3 -right-4 w-24 h-24 rotate-12" aria-hidden="true"></span>
<div class="panel-head relative px-6 py-6 md:px-8">
<p class="label text-bakery-600" th:text="${tier.label}">Small</p>
<p th:if="${tier.price}" class="price mt-2 text-bakery-900" th:text="${tier.price}">$24</p>
<!--/* No price means "ask us" — say so rather than leaving a hole in the card. */-->
<p th:unless="${tier.price}" class="price mt-2 text-2xl text-bakery-700">Ask us</p>
<!--/* "Small" doesn't tell anyone how many it feeds. A column whose heading is already a
head count leaves this blank rather than saying it twice. */-->
<p th:if="${tier.serves}" class="mt-1 text-sm text-bakery-600" th:text="${tier.serves}">About 68 people</p>
</div>
<ul class="px-6 py-6 md:px-8 space-y-3 text-bakery-800">
<li th:each="row : ${table.rows}" class="flex gap-3">
<!--/* A tick, not a bullet: this is a list of what you get, not prose. */-->
<svg class="mt-1 h-4 w-4 shrink-0 text-bakery-500" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
<span>
<span th:text="${row.label}">Mini muffins</span>
<!--/* A line the bakery hasn't quantified for this size still belongs in the box; it
simply appears without a number, rather than as an empty cell in a grid. */-->
<span th:if="${!#strings.isEmpty(row.values[t.index])}" class="text-bakery-600"
th:text="|&mdash; ${row.values[t.index]}|">&mdash; a dozen</span>
</span>
</li>
</ul>
</article>
</div>
<!--/*
The small print, then one link for the whole table.
A button on every card was the obvious pricing-page move and it was wrong twice over: all
three cards led to the same place, since an enquiry is about the table rather than the size,
and the label read "Ask about the 1520 people" on the tables whose sizes are headcounts.
*/-->
<div class="panel-quiet mt-8 measure px-6 py-6 text-center">
<div th:if="${!#lists.isEmpty(table.notes)}">
<h3 class="label text-bakery-500">Good to know</h3>
<ul class="mt-3 space-y-2 text-sm text-bakery-700">
<li th:each="note : ${table.notes}" th:text="${note}">Everything is baked in sixes.</li>
</ul>
</div>
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
class="pill-outline mt-6 no-print"
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office boxes</a>
</div>
</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="link-plain">(309) 701-0660</a> and we will
talk it through.
</p>
</div>
</div>
</div>
<!--/* The terms that apply whichever table you were reading, and the one real call to action. On sage,
like the homepage's story band, so the page ends rather than trailing off. */-->
<section class="section bg-bakery-800 text-white no-print">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Before you order</h2>
<ul th:if="${!#lists.isEmpty(menu.notes)}" class="mt-6 space-y-3 text-bakery-100 leading-relaxed">
<li th:each="note : ${menu.notes}" th:text="${note}">These are a guide rather than a menu.</li>
</ul>
<div class="mt-10 flex flex-col sm:flex-row gap-3 justify-center">
<a href="/contact"
class="pill-cream">
Tell us what you need
</a>
<a href="tel:+13097010660"
class="pill-ghost">
(309) 701-0660
</a>
</div>
</div>
</div>
</section>
</div>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!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">
<div th:replace="~{fragments/opening :: plain(
'Say hello',
'Contact us',
'Ask about a cake, a date, or an order for a crowd. We read everything that comes in.')}"></div>
<!--/*
The form, and beside it the things somebody on this page is usually actually after: when we are open,
where we are, and the phone number. They were on the homepage and in the footer but not here, which
made this page a form and nothing else.
*/-->
<div class="container pb-16 md:pb-24">
<div class="grid gap-8 lg:grid-cols-[1.4fr_1fr] measure-wide items-start">
<div class="panel p-8 md:p-10">
<h2 class="h3 text-bakery-900 mb-2">Get in touch</h2>
<p class="text-bakery-700 mb-8">
Or call us: <a href="tel:+13097010660" class="link-plain">(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="field">
</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="field">
</div>
<!--/*
The spam trap. Not display:none — the crawlers that matter check for that — but positioned off
the page, skipped by the tab key and told not to autofill. A person never sees it; a bot fills in
every field it finds. See SiteController#submit for what happens then.
*/-->
<div class="absolute left-[-9999px]" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</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="field"></textarea>
</div>
<div class="flex flex-col items-center gap-4">
<button type="submit"
class="pill-sage">
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="link-plain">(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>
<aside class="space-y-6">
<div class="panel p-6 md:p-8">
<h2 class="h4 text-bakery-900">Our hours</h2>
<div class="mt-4 text-bakery-800"
th:replace="~{fragments/visit :: hours('text-bakery-800')}"></div>
</div>
<div class="panel p-6 md:p-8">
<h2 class="h4 text-bakery-900">Find us</h2>
<div class="mt-4 text-bakery-800"
th:replace="~{fragments/visit :: address('link-plain')}"></div>
</div>
<div class="panel-quiet p-6 md:p-8">
<h2 class="h4 text-bakery-900">Ordering for a crowd?</h2>
<p class="mt-2 text-sm text-bakery-700 leading-relaxed">
Goodie boxes, party packages and wedding desserts have their own page, with sizes and prices.
</p>
<a href="/catering" class="link mt-4 inline-block font-medium">
Goodie boxes &amp; catering
</a>
</div>
</aside>
</div>
</div>
</div>
</body>
</html>
+29
View File
@@ -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 py-24 md:py-32 text-center">
<h1 class="h1 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="link-plain">(309) 701-0660</a>.
</p>
<a href="/"
class="pill-sage">
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 py-24 md:py-32 text-center">
<h1 class="h1 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="pill-sage">
Back home
</a>
<a href="/products"
class="pill-outline">
See the menu
</a>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<footer th:fragment="footer" class="bg-bakery-900 text-bakery-100">
<div class="container 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="label text-bakery-300 mb-4 font-adbhashitha">Navigation</h3>
<ul class="space-y-2">
<li><a href="/products" class="hover:text-white transition-colors">Our Products</a></li>
<li><a href="/catering" class="hover:text-white transition-colors">Goodie Boxes &amp; Catering</a></li>
<li><a href="/history" class="hover:text-white transition-colors">Our Story</a></li>
<li><a href="/contact" class="hover:text-white transition-colors">Contact</a></li>
</ul>
</div>
<div>
<h3 class="label text-bakery-300 mb-4 font-adbhashitha">Visit</h3>
<div th:replace="~{fragments/visit :: address('link-on-dark')}"></div>
<!--/* The hours are the thing people come to a bakery's site for, and the footer is on every
page. */-->
<div class="mt-4 text-sm text-bakery-200"
th:replace="~{fragments/visit :: hours('text-sm text-bakery-200')}"></div>
</div>
</div>
<div class="mt-12 text-center text-sm text-bakery-300">
<p>&copy; <span th:text="${year}">2026</span> The Vine Coffeehouse + Bakery</p>
</div>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,52 @@
<!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>
<!--/* All three are above the fold — the wordmark, every heading, and the body — and all three together
are now smaller than the display face alone used to be. Without preloading, each page opens in
the fallback and then reflows into the brand. */-->
<link rel="preload" as="font" type="font/woff2" href="/fonts/raleway-latin.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/LeJour-Script.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/AdBhashitha.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)}">
<!--/* Without an image a shared link is a grey box with a title. This is the storefront. */-->
<meta property="og:image" th:content="${shareImage}">
<meta property="og:image:alt" content="The Vine Coffeehouse + Bakery on Main Street, Princeville">
<meta name="twitter:card" content="summary_large_image">
<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}|">
<!--/* The shop as a search engine reads it: a Bakery, its address, its phone number and its opening
times, from the same record the page prints. It is how a local shop gets "Open ⋅ closes 2pm"
next to its listing. Unescaped because it is JSON, written by Jackson — see StructuredData. */-->
<script type="application/ld+json" th:utext="${structuredData}"></script>
</head>
<body></body>
</html>
@@ -0,0 +1,77 @@
<!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">
<div class="flex items-center justify-between gap-2 h-20 md:h-24">
<div th:replace="~{fragments/lockup :: small('text-bakery-700')}"></div>
<!--/*
The status line is the first thing to go when the bar gets tight: at the width of a small laptop
the wordmark and the nav were overlapping, because "Closed · opens Tuesday at 7:00am" is as wide as
two nav items. It comes back at lg, and the mobile menu carries it at every width below md.
*/-->
<div class="hidden md:flex items-center gap-6 xl:gap-8">
<nav class="flex items-center gap-6 xl:gap-8">
<th:block th:replace="~{:: links('label text-bakery-700 hover:text-bakery-900 transition-colors')}"></th:block>
</nav>
<!--/* Worked out on the server from the same opening times the footer prints. */-->
<p th:replace="~{fragments/visit :: open-now(
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
'hidden xl:inline-flex text-sm text-bakery-600 whitespace-nowrap')}"></p>
</div>
<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 flex flex-col">
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition-colors py-4 text-lg border-b border-bakery-100')}"></th:block>
<p th:replace="~{fragments/visit :: open-now(
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
'inline-flex text-sm text-bakery-600 py-4')}"></p>
</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.
The page you are on is marked: aria-current for a screen reader, and a sage rule under the word for
everyone else. A four-page site can get away without it, right up until someone clicks Catering, lands
on a page of prices, and has no idea whether the click worked.
*/-->
<th:block th:fragment="links(itemClass)">
<a th:each="item : ${ {{'/products','Our Products'},{'/catering','Catering'},{'/history','Our Story'},{'/contact','Contact'}} }"
th:href="${item[0]}" th:text="${item[1]}"
th:aria-current="${path == item[0] ? 'page' : null}"
th:class="${itemClass} + (${path == item[0]} ? ' is-here' : '')">Our Products</a>
</th:block>
</body>
</html>
@@ -0,0 +1,46 @@
<!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 components.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.
Every size here steps at a breakpoint, because the lockup is three things that have to stay in
proportion: two fixed-width marks and a tagline that cannot wrap. Give a phone the desktop sizes and
they add up to more than its width, and the only part that CAN give — the name — wraps to two lines with
the marks centred against it. Which is what it did.
*/-->
<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 whitespace-nowrap ' + ${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-11 h-11 sm:w-14 sm:h-14',
'text-xl sm:text-2xl xl:text-3xl',
'text-[0.55rem] sm:text-xs tracking-[0.12em] sm: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-14 h-14 sm:w-20 sm:h-20 md:w-28 md:h-28',
'text-3xl sm:text-5xl md:text-6xl',
'text-[0.6rem] sm:text-base tracking-[0.15em] sm:tracking-[0.2em]')}"></th:block>
</div>
</body>
</html>
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!--/*
How a page begins. Every page, the same three things in the same order: a small-caps line, the title in
the display face, and one sentence saying what this is.
Two dresses for it. `photographed` is for the pages that are selling or telling — catering and the story
— where a blurred photo under the sage wash does the work the homepage hero does. `plain` is for the
pages you arrive at already knowing what you want, where a photograph would be in the way of the thing
you came for: the catalogue, the contact form.
Before this the four openings were four different pieces of markup that happened to look similar.
*/-->
<section th:fragment="photographed(eyebrow, title, lede, image)"
class="band relative flex items-center bg-bakery-900 text-white overflow-hidden">
<img th:src="${photos.of(image)}" alt=""
class="absolute inset-0 w-full h-full object-cover 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>
<div class="relative container w-full min-w-0">
<div class="measure text-center">
<p class="eyebrow text-bakery-200" th:text="${eyebrow}">Order ahead</p>
<h1 class="h1 mt-3 text-bakery-50" th:text="${title}">Goodie boxes &amp; catering</h1>
<p class="lede mt-5 text-bakery-100" th:text="${lede}">One line about the page.</p>
</div>
</div>
</section>
<header th:fragment="plain(eyebrow, title, lede)" class="page-head">
<p class="eyebrow text-bakery-500" th:text="${eyebrow}">From the counter</p>
<h1 class="h1 mt-3 text-bakery-900" th:text="${title}">Our products</h1>
<p class="lede mt-4 measure text-bakery-800" th:text="${lede}">One line about the page.</p>
</header>
<!--/*
How a section begins, as opposed to a page: the title, centred, and optionally a line under it. Pass an
empty string for no line.
It was five different pieces of markup — centred with one margin, centred with another, left-aligned
with a blurb — for what is one idea repeated down every page.
*/-->
<div th:fragment="section-head(title, blurb)" class="measure text-center">
<h2 class="h2 text-bakery-900" th:text="${title}">What people come in for</h2>
<p th:if="${!#strings.isEmpty(blurb)}" class="mt-3 text-bakery-700 leading-relaxed" th:text="${blurb}">A line.</p>
</div>
<!--/* The same, on the sage bands, where the type is cream. */-->
<div th:fragment="section-head-dark(title, blurb)" class="measure text-center">
<h2 class="h2" th:text="${title}">Before you order</h2>
<p th:if="${!#strings.isEmpty(blurb)}" class="mt-3 text-bakery-100 leading-relaxed" th:text="${blurb}">A line.</p>
</div>
<!--/* The branch from the wordmark, between one part of a page and the next. */-->
<div th:fragment="divider" class="flex justify-center" aria-hidden="true">
<span class="mark mark-r w-12 h-12 text-bakery-400"></span>
</div>
</body>
</html>
@@ -0,0 +1,31 @@
<!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">
<!--/* Hidden until it has focus: the first thing a keyboard reaches, so the nav isn't a toll gate on
every page. */-->
<a href="#content" class="skip-link">Skip to the page</a>
<div th:replace="~{fragments/header :: header}"></div>
<main id="content" 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,44 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!--/*
The opening times and the address, printed from the shop's own record rather than from markup.
They were three hand-written lines repeated in three templates. Now `Hours` collapses the week into the
lines a sign would show — consecutive days that keep the same times become one row — and the same data
answers "are they open right now?" in the header and describes the shop to a search engine. A shop that
changes its Saturday hours changes them in Shop.java, once.
Both fragments take the classes that vary by where they sit — cream in the footer, sage on white in a
card — so the content is shared without the styling being averaged into something that suits neither.
*/-->
<ul th:fragment="hours(rowClass)" class="space-y-3">
<li th:each="span : ${hours}" th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span th:text="${span.days}">Tuesday Friday</span>
<span class="font-medium whitespace-nowrap" th:text="${span.hours}">7:00am 2:00pm</span>
</li>
</ul>
<address th:fragment="address(linkClass)" class="not-italic space-y-3">
<p th:text="${shopStreet}">215 E Main Street</p>
<p th:text="${shopTown}">Princeville, IL 61559</p>
<p>
<a th:href="|tel:${shopPhone}|" th:class="${linkClass}" th:text="${shopPhoneSpoken}">(309) 701-0660</a>
</p>
<p class="break-words">
<a th:href="|mailto:${shopEmail}|" th:class="${linkClass}" th:text="${shopEmail}">[email protected]</a>
</p>
</address>
<!--/* "Open until 2:00pm", or when it opens next. The one thing a visitor at 2:30 on a Sunday wants. */-->
<!--/* The caller passes the display too (`inline-flex`, or `hidden lg:inline-flex`): setting it here as
well would be two rules for one property, and the winner would depend on Tailwind's output order
rather than on the markup. */-->
<span th:fragment="open-now(dotClass, textClass)"
th:class="'items-center gap-2 ' + ${textClass}">
<span th:class="'h-2 w-2 rounded-full shrink-0 ' + ${dotClass}" aria-hidden="true"></span>
<span th:text="${openNow.summary}">Open until 2:00pm</span>
</span>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
<!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">
<div th:replace="~{fragments/opening :: photographed(
'Since 2024',
'Our story',
'215 E Main Street, in the middle of downtown Princeville.',
'gallery/Outside.webp')}"></div>
<div class="container pb-16 md:pb-24">
<div class="measure space-y-12">
<section>
<h2 class="h3 text-bakery-900 mb-4">How it started</h2>
<p class="opening-para 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="h3 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>
<figure>
<img th:src="${photos.of('gallery/Cinnamon Rolls.webp')}"
alt="Cinnamon rolls, iced, on the counter at The Vine"
width="1200" height="800" loading="lazy"
class="photo w-full h-64 md:h-80 object-cover">
<figcaption class="mt-3 label text-bakery-500 text-center">The cinnamon rolls, most mornings</figcaption>
</figure>
<section>
<h2 class="h3 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>
<figure>
<img th:src="${photos.of('gallery/Cakes.webp')}"
alt="A decorated cake made to order at The Vine"
width="1200" height="800" loading="lazy"
class="photo w-full h-64 md:h-80 object-cover">
<figcaption class="mt-3 label text-bakery-500 text-center">Made to order, whatever the week is celebrating</figcaption>
</figure>
<section>
<h2 class="h3 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&apos;s small businesses. Enjoy
Illinois and Discover Peoria have both pointed travelers our way. If you are one of them,
we open at 7:00am, Tuesday through Saturday.
</p>
</section>
</div>
</div>
<!--/* Somewhere to go after reading: the story is a preamble to an order. */-->
<section class="section bg-bakery-800 text-white">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Come and see us</h2>
<p class="mt-4 text-bakery-100 leading-relaxed">
We open at 7:00am, Tuesday through Saturday. If you are after something for a date in the
diary, it is worth asking early.
</p>
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<a href="/products"
class="pill-cream">
See what we make
</a>
<a href="/contact"
class="pill-ghost">
Get in touch
</a>
</div>
</div>
</div>
</section>
</div>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
<!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.
56svh, not 78: at 78 a laptop's first screen was the lockup and nothing else, so the page looked like
it had one thing on it. The floor is low enough now that the next section shows above the fold and
invites the scroll, and high enough that the lockup still has room to be the first thing you see. It
is a floor, not a height — the content wins on a short window.
*/-->
<section class="relative flex items-center min-h-[56svh] py-16 md:py-20 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 w-full min-w-0">
<div class="measure text-center">
<div th:replace="~{fragments/lockup :: large('text-bakery-50 mb-8')}"></div>
<p class="lede sm:text-xl text-bakery-100 mb-8">
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="pill-cream">
See the menu
</a>
<a href="#visit"
class="pill-ghost">
Visit us
</a>
</div>
</div>
</div>
</section>
<!--/* What people come in for */-->
<section class="section bg-bakery-50">
<div class="container">
<div class="mb-12" th:replace="~{fragments/opening :: section-head('What people come in for', '')}"></div>
<div class="card-grid">
<div th:each="item : ${ {'Cinnamon Rolls', 'Sugar Cookies', 'Cakes'} }"
class="panel overflow-hidden text-center">
<!--/* 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">
<h3 class="panel-head border-b-0 border-t h4 md:text-2xl text-bakery-800 py-6" th:text="${item}">Cinnamon Rolls</h3>
</div>
</div>
</div>
</section>
<!--/*
Ordering ahead. The catering page exists and the homepage said nothing about it — a customer only
found it by reading the nav. Photo on one side, the offer on the other, and the three tables named so
the link is worth following.
*/-->
<section class="section bg-bakery-100">
<div class="container">
<div class="grid md:grid-cols-2 gap-8 md:gap-12 items-center measure-wide">
<img th:src="${photos.of('gallery/Sugar Cookies.webp')}" alt="Iced sugar cookies from The Vine"
width="800" height="600" loading="lazy"
class="photo w-full h-64 md:h-80 object-cover">
<div>
<h2 class="h2 text-bakery-900">Ordering for a crowd?</h2>
<p class="mt-4 text-bakery-800 leading-relaxed">
Goodie boxes for the office, packages for a party, and cakes and desserts for a wedding —
with prices, sizes and what each one includes, so you can see where to start.
</p>
<a href="/catering"
class="pill-sage mt-6">
Goodie boxes &amp; catering
</a>
</div>
</div>
</div>
</section>
<!--/* Our story */-->
<section class="section bg-bakery-800 text-white">
<div class="container">
<div class="measure text-center">
<h2 class="h2">Our story</h2>
<p class="lede mt-4 text-bakery-100 mb-8">
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="link-on-dark font-semibold">
Read our story
</a>
</div>
</div>
</section>
<!--/* Visit us */-->
<section id="visit" class="section bg-bakery-50 scroll-mt-24">
<div class="container">
<div class="mb-12" th:replace="~{fragments/opening :: section-head('Visit us', '')}"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 measure-wide">
<div class="panel p-6 md:p-8">
<h2 class="h3 text-bakery-900 mb-6">Our hours</h2>
<div th:replace="~{fragments/visit :: hours('text-bakery-800')}"></div>
</div>
<div class="panel p-6 md:p-8">
<h2 class="h3 text-bakery-900 mb-6">Find us</h2>
<div class="text-bakery-800"
th:replace="~{fragments/visit :: address('link-plain')}"></div>
</div>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -0,0 +1,94 @@
<!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">
<div th:replace="~{fragments/opening :: plain(
'From the counter',
'Our products',
'What comes out of the kitchen on Main Street. Cakes and decorated cookies are made to order, so most of what follows started as somebody describing what they wanted.')}"></div>
<div class="container 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="filter-row 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="${category == selected} ? 'chip-on' : 'chip'">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="card-grid">
<div th:each="product : ${products}"
class="panel overflow-hidden">
<div class="relative w-full overflow-hidden">
<div th:replace="~{:: gallery(${product})}"></div>
</div>
<!--/* The caption sits on the cream, so the photo reads as mounted on the card rather than as
the card itself. */-->
<div class="panel-head border-b-0 border-t px-6 py-5 text-center">
<h3 class="h4 text-bakery-900" th:text="${product.name}">Name</h3>
<span class="label 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="link-plain">See everything</a>.
</p>
</div>
<!--/* A catalogue with no way to order from it is a gallery. */-->
<section class="section bg-bakery-800 text-white no-print">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Want one of these, or something else?</h2>
<p class="mt-4 text-bakery-100 leading-relaxed">
Cakes and decorated cookies are made to order. Tell us the date and roughly what you have in
mind — a tractor, a retirement, the class of 1964 — and we will work from there.
</p>
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<a href="/contact"
class="pill-cream">
Ask about an order
</a>
<a href="/catering"
class="pill-ghost">
Ordering for a crowd
</a>
</div>
</div>
</div>
</section>
</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>
+47
View File
@@ -0,0 +1,47 @@
/*
* The admin's controls.
*
* Component classes rather than utility strings repeated through the markup, because the admin is made
* of forms: there are dozens of buttons on a page and each one is its own <form>. The public pages keep
* their utilities inline — they each look different — but "a button in the admin" should be one decision
* in one place. These are the same buttons the React screen had, from the same class strings it composed.
*
* @utility, not `.btn { @apply … }` in a components layer: Tailwind v4 will only let you @apply a class
* it knows as a utility, and only a registered utility can take a variant — which `file:btn-secondary`
* on the photo pickers needs.
*/
@utility btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-field px-3 py-1.5 text-sm font-medium
transition-colors;
}
@utility btn-primary {
@apply btn bg-bakery-600 text-white hover:bg-bakery-700
disabled:opacity-40 disabled:cursor-not-allowed;
}
@utility btn-secondary {
@apply btn border border-bakery-300 text-bakery-800 hover:bg-bakery-100
disabled:opacity-40 disabled:cursor-not-allowed;
}
@utility btn-danger {
@apply btn text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed;
}
/* Square, for the arrows and the crosses — a row of these is how anything gets reordered. */
@utility btn-icon {
@apply inline-flex items-center justify-center w-7 h-7 rounded-field border border-bakery-300
text-bakery-700 hover:bg-bakery-100 transition-colors
disabled:opacity-30 disabled:cursor-not-allowed;
}
/* `field` is shared with the public form — see components.css. */
@utility card {
@apply rounded-panel border border-bakery-200 bg-white p-4 shadow-sm;
}
@utility card-heading {
@apply font-adbhashitha text-xl text-bakery-800;
}
+133
View File
@@ -0,0 +1,133 @@
/*
* Element defaults: the page background, the body type, the fade-in, and the two small resets the site
* needs. Anything with a class name belongs in components.css instead.
*/
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);
}
}
::selection {
background-color: var(--color-bakery-300);
color: var(--color-bakery-900);
}
/* The mobile menu is a <details>; its default disclosure triangle would sit beside the hamburger. Lost
once already in a refactor, which is exactly the kind of thing nobody notices until a phone. */
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
/*
* FOCUS. One visible ring for everything you can reach with a keyboard, as an outline rather than a
* shadow so it shows up on cream, on white and on the sage bands without needing three variants.
* :where() keeps its specificity at zero, so a utility can still override it.
*/
:where(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--color-bakery-500);
outline-offset: 3px;
border-radius: 3px;
}
/*
* On the sage bands that same sage is about 2.5:1 against the background — under the 3:1 a focus
* indicator needs, so the ring effectively disappeared exactly where the page's main calls to action
* live. Cream on those sections.
*/
:where(.bg-bakery-800, .bg-bakery-900) :where(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
outline-color: var(--color-bakery-100);
}
/*
* THE Z-INDEX SCALE, in full. Two layers is all this site has, and writing them down is what stops a
* third being invented at 9999:
*
* 40 the sticky header
* 30 the mobile menu panel, which is a child of the header and therefore already inside its stacking
* context — it can only ever sit under the bar, which is exactly where it belongs
*
* Everything else stacks in document order: the hero's tint sits over its photo because it comes after
* it, and the product-card arrows sit over the photos for the same reason.
*/
/*
* A native select still gets its own arrow from the platform, in the platform's colour. This is the one
* control the admin uses that we don't draw ourselves, so it gets the site's chevron instead.
*/
select.field {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234a5740' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 1.1rem;
padding-right: 2.4rem;
}
/*
* PRINT. The catering page is a price list, and a bakery prints price lists — so what comes out of a
* printer should be the prices, not a screenshot of a website. The chrome goes, the sage bands go (they
* would eat a cartridge), the cards keep their edges so the columns stay readable, and links stop being
* blue-and-underlined because paper has no links.
*/
@media print {
header,
footer,
.no-print {
display: none !important;
}
body {
background: #fff;
color: #000;
opacity: 1;
animation: none;
}
/* Sage-on-white blocks become plain text: the words matter, the ink does not. */
[class*="bg-bakery-8"],
[class*="bg-bakery-9"] {
background: #fff !important;
color: #000 !important;
}
a {
text-decoration: none;
color: inherit;
}
/* A card should not be split across a page break mid-price. */
article,
section {
break-inside: avoid;
}
}
+276
View File
@@ -0,0 +1,276 @@
/*
* The public site's own classes — the ones that would be unreadable as a string of utilities in every
* template that needs them. The admin's controls are in admin.css; the type ladder is in type.css.
*
* Almost everything here is @utility rather than a plain class in a layer, for two reasons Tailwind v4
* enforces: only a registered utility can be @applied by another rule (panel-mark builds on mark), and
* only a registered utility can take a variant (`hover:`, `md:`, `file:`).
*/
/*
* The page gutter, and the only plain class left in a layer.
*
* Every section used to write `container mx-auto px-4`, carried over from the React site: `mx-auto` twice
* and a px-4 that beat the responsive padding below, so the gutter was 16px at every width including a
* 27-inch screen. The markup now says `container` and nothing else, which is why this can stay in the
* components layer — there is no longer a utility next to it that has to win.
*/
@layer components {
.container {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
}
/*
* MEASURES. Text you read wants a narrow column and a grid of cards wants a wide one; those two numbers
* were seven different max-w-* classes chosen a template at a time.
*/
@utility measure {
@apply max-w-2xl mx-auto;
}
@utility measure-wide {
@apply max-w-5xl mx-auto;
}
/*
* LINKS in running text. Underlined, offset so the line clears the descenders, and colour-shifting on
* hover — three variants because a link in a paragraph, a link on a sage band and a quiet one in small
* print are the same idea in different rooms.
*/
@utility link {
@apply underline underline-offset-4 text-bakery-700 hover:text-bakery-900 transition-colors;
}
@utility link-on-dark {
@apply underline underline-offset-4 text-bakery-50 hover:text-white transition-colors;
}
/* Inherits its colour: for a link inside a coloured paragraph that already sets one. */
@utility link-plain {
@apply underline underline-offset-4 hover:text-bakery-600 transition-colors;
}
/*
* FIELDS. One input, everywhere. The public form had its own (cream, ring-2, py-2.5) and the admin had
* another (white, ring-1, py-2, text-sm), which is what happens when a form gets written twice.
*/
/*
* text-base on a phone, not text-sm: Safari zooms the whole page in when you focus an input smaller than
* 16px, which on the contact form means every enquiry typed on a phone starts by throwing the layout
* sideways. The denser size comes back at sm, where no browser does that.
*/
@utility field {
@apply w-full rounded-field border border-bakery-300 bg-white px-3.5 py-2.5 text-base sm:text-sm
focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500;
}
/* A photograph standing on the page rather than inside a card: same edge and shadow as one. */
@utility photo {
@apply rounded-panel border border-bakery-200/70;
box-shadow: var(--shadow-card);
}
/* The cream header at the top of a page that has no photographic band. */
@utility page-head {
@apply container pt-14 pb-10 md:pt-20 md:pb-12 text-center;
}
/*
* THE 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.
*/
@utility 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;
}
@utility mark-r {
-webkit-mask-image: url('/images/logo_R.svg');
mask-image: url('/images/logo_R.svg');
}
@utility 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.
*/
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/*
* SURFACES.
*
* These were plain white boxes with a grey drop shadow — the default card of every web app, on a page
* that is otherwise cream, sage and a hand-drawn vine. Three things make them the shop's:
*
* a warm hairline, so a card has an EDGE and reads as stock rather than as a floating panel;
* a shadow tinted with the darkest sage instead of black (see theme.css);
* a head that can be tinted cream, the way a printed menu separates the price from what you get.
*
* `panel-lift` is one you can click, and it rises rather than just darkening.
*/
@utility panel {
@apply bg-white rounded-panel border border-bakery-200/70;
box-shadow: var(--shadow-card);
}
@utility panel-lift {
@apply panel transition duration-300;
&:hover {
box-shadow: var(--shadow-card-lift);
transform: translateY(-2px);
}
}
/* The cream band on a card: a size and its price, or a photo's caption. */
@utility panel-head {
@apply bg-bakery-50/70 border-b border-bakery-200/70;
}
/*
* A card that is holding an aside rather than the thing you came for: the small print under a price
* table, a pointer to another page. Tinted and bordered, no shadow, so it sits ON the page instead of
* above it and does not compete with the cards it follows. There were two hand-rolled versions of this
* before it had a name, and they had different borders.
*/
@utility panel-quiet {
@apply rounded-panel border border-bakery-200/70 bg-bakery-50/60;
}
/*
* A grid of cards. It was written three times with three different sets of breakpoints and gaps — the
* homepage went to three columns at md, the catering tables at lg, the catalogue at lg with a wider gap —
* so cards on different pages lined up differently for no reason anyone chose.
*/
@utility card-grid {
@apply grid gap-6 md:gap-8 sm:grid-cols-2 lg:grid-cols-3;
}
/*
* The branch, laid into a corner at the weight of a watermark. It is the one mark that is unmistakably
* this shop, and at this size and colour it is texture rather than decoration.
*/
@utility panel-mark {
@apply mark mark-l absolute pointer-events-none text-bakery-200/70;
}
/*
* BUTTONS. One shape — a full pill, generous, letterspaced — in four colourways, because the site puts
* buttons on cream and on sage and they cannot be the same colour on both.
*
* pill-sage filled, on a light background pill-outline quiet, on a light background
* pill-cream filled, on a sage background pill-ghost quiet, on a sage background
*/
@utility pill {
@apply inline-flex items-center justify-center gap-2 rounded-full
px-8 py-3.5 font-medium tracking-wide transition-colors;
}
@utility pill-sage {
@apply pill bg-bakery-600 text-white hover:bg-bakery-700;
}
@utility pill-cream {
@apply pill bg-bakery-50 text-bakery-900 hover:bg-white shadow-lg;
}
@utility pill-outline {
@apply pill border border-bakery-300 text-bakery-800 hover:bg-bakery-100;
}
@utility pill-ghost {
@apply pill border border-bakery-200/60 text-bakery-50 hover:bg-bakery-50/10;
}
/*
* Smaller than a pill, for a filter row rather than a call to action — but still 44px tall, which is the
* smallest thing a thumb should be asked to hit.
*/
@utility chip {
@apply inline-flex items-center justify-center rounded-full border px-5 sm:px-6 min-h-11 label
whitespace-nowrap bg-white border-bakery-300 text-bakery-700 transition-colors hover:bg-bakery-100;
}
@utility chip-on {
@apply chip bg-bakery-600 border-bakery-600 text-white hover:bg-bakery-600;
}
/*
* RHYTHM. Two vertical sizes, so the page has a beat instead of nine hand-picked paddings: `section` for
* a block of content, `band` for a photographic or coloured strip, which sits tighter because its
* background is already doing the separating.
*/
@utility section {
@apply py-16 md:py-24;
}
@utility band {
@apply py-14 md:py-20;
}
/*
* A row of filters that scrolls sideways on a phone instead of wrapping onto three lines with one item
* stranded on the last. It wraps and centres from sm, where there is room. The negative margin lets the
* strip bleed to the screen edge so a half-visible chip shows there is more.
*/
@utility filter-row {
@apply flex gap-3 overflow-x-auto no-scrollbar snap-x -mx-4 px-4
sm:flex-wrap sm:justify-center sm:gap-4 sm:overflow-visible sm:mx-0 sm:px-0;
& > * {
@apply snap-start;
}
}
/*
* THE PAGE YOU ARE ON, in the nav. A rule under the word rather than a colour change: the nav is already
* sage on cream, and darkening it further reads as a hover, not as "you are here".
*/
@utility is-here {
@apply text-bakery-900;
text-decoration: underline;
text-decoration-color: var(--color-bakery-500);
text-decoration-thickness: 2px;
text-underline-offset: 8px;
}
/*
* The keyboard's way past the nav. Off-screen until focused, then a normal-looking button in the corner —
* the pattern only works if it becomes visible, which is the half everyone forgets.
*/
@utility skip-link {
@apply sr-only;
&:focus {
@apply not-sr-only fixed left-4 top-4 z-50 pill-sage;
}
}
/*
* An opening paragraph set like a printed one. Used once, on the story — a drop cap on every page would
* be a costume rather than a voice.
*/
@utility opening-para {
&::first-letter {
@apply font-adbhashitha text-6xl text-bakery-700 float-left mr-3 mt-1;
line-height: 0.75;
}
}
+1202
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "itsthevine-styles",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --minify",
"build:css": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --minify",
"watch": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --watch"
},
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
"tailwindcss": "4.3.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
/*
* The site's one stylesheet, compiled by the Tailwind CLI into target/classes/static/css.
*
* It lives under src/main with the rest of the app's source, and it has to stay in the same directory as
* package.json: Tailwind resolves `@import "tailwindcss"` by walking up from the CSS file looking for
* node_modules. There is no bundler and no framework here — the site and the admin are both
* server-rendered HTML, and this is the whole of the asset pipeline.
*
* Imported in cascade order: names, then bare elements, then classes.
*/
@import "tailwindcss";
@import "./theme.css";
@import "./base.css";
@import "./type.css";
@import "./components.css";
@import "./admin.css";
/*
* Where Tailwind looks for class names. Every template, and 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.
*/
@source "../resources/templates";
@source "../resources/static/js";
+72
View File
@@ -0,0 +1,72 @@
/*
* The brand: three typefaces and the sage-and-cream palette. Nothing here draws anything — these are the
* names the rest of the CSS and every template are written in.
*
* Fonts are served by Spring from /fonts rather than bundled, so the stylesheet can name a stable URL and
* the browser caches one copy across the whole site.
*/
@font-face {
font-family: 'Raleway';
src: url('/fonts/raleway-latin.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/*
* 306 KB became 8 KB. The file carried 1605 glyphs for 187 codepoints — alternates and scripts this site
* never sets — so it is subset to Latin and the punctuation it actually has, with every layout feature
* kept, and compressed as woff2. The headings are pixel-identical; that was checked, not assumed.
*/
@font-face {
font-family: 'AdBhashitha';
src: url('/fonts/AdBhashitha.woff2') format('woff2');
font-display: swap;
}
/*
* Recompressed, never subset: the wordmark's swashes come out of this font's alternates, and the logo is
* the one thing on the site that must not change shape.
*/
@font-face {
font-family: 'LeJour Script';
src: url('/fonts/LeJour-Script.woff2') format('woff2');
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;
/*
* CORNERS. Two decisions, named, so "how round is a card" is answered once rather than per template.
*
* A card is 12px. It was 24px (Tailwind's rounded-3xl), which is the roundest thing on the page and
* reads as a phone app; a bakery's printed card has a corner you can see. Fields are tighter again, and
* buttons stay fully round — the pill is the brand's button shape and always was.
*/
--radius-panel: 0.75rem;
--radius-field: 0.5rem;
/*
* Shadows tinted with the darkest sage rather than black. A neutral grey shadow on a cream page reads
* as a UI panel floating over a screen; a warm one reads as card stock lying on a counter, which is
* what these are meant to be.
*/
--shadow-card: 0 1px 2px rgba(35, 43, 30, 0.04), 0 10px 30px -18px rgba(35, 43, 30, 0.28);
--shadow-card-lift: 0 2px 6px rgba(35, 43, 30, 0.06), 0 20px 44px -22px rgba(35, 43, 30, 0.4);
--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;
}
+71
View File
@@ -0,0 +1,71 @@
/*
* The typographic ladder, taken from the logo and walked down to something you can read a paragraph in.
*
* The lockup is the brand's whole voice: "The Vine" in LeJour Script over COFFEEHOUSE + BAKERY in
* AdBhashitha, letterspaced. Everything below is that idea, made progressively more readable:
*
* 1. .wordmark LeJour Script the name, and nothing else. A script face is unreadable at length,
* so it never leaves the lockup.
* 2. .h1 .h2 .h3 AdBhashitha the lockup's second font, carrying every heading. It keeps the
* wordmark's 0.01em letter-spacing so a heading sits on the same
* rhythm as the logo above it.
* 3. .eyebrow Raleway, caps the hinge: sans, but letterspaced like the lockup's tagline. Used
* .label above a heading and on small labels, it carries the brand into
* places a display face would be shouting.
* 4. .lede, body Raleway plain, and left alone.
*
* Sizes are declared here rather than in the templates so a heading is a decision made once. None of
* these set a colour: the same heading appears in sage on cream and in cream on sage, and a class that
* decided that would be fought with a utility every time.
*/
@utility wordmark {
font-family: var(--font-lejour);
letter-spacing: 0.01em;
}
@utility h1 {
@apply font-adbhashitha text-4xl md:text-5xl leading-tight text-balance;
letter-spacing: 0.01em;
}
@utility h2 {
@apply font-adbhashitha text-3xl md:text-4xl leading-tight text-balance;
letter-spacing: 0.01em;
}
@utility h3 {
@apply font-adbhashitha text-2xl md:text-3xl leading-snug;
letter-spacing: 0.01em;
}
@utility h4 {
@apply font-adbhashitha text-xl leading-snug;
letter-spacing: 0.01em;
}
/* Above a heading, or as a section's own small heading. */
@utility eyebrow {
@apply text-xs sm:text-sm uppercase font-medium;
letter-spacing: 0.2em;
}
/* Smaller, tighter: a column heading, a "good to know", a category chip. */
@utility label {
@apply text-xs uppercase font-medium;
letter-spacing: 0.15em;
}
/* The sentence under a page title. One size up from body, and balanced so it doesn't leave an orphan. */
@utility lede {
@apply text-base sm:text-lg leading-relaxed text-balance;
}
/*
* A price. The display face at a size nothing else uses, because on a catering card the number is the
* thing the eye should land on after the size.
*/
@utility price {
@apply font-adbhashitha text-4xl leading-none;
letter-spacing: 0.01em;
}
@@ -0,0 +1,222 @@
package com.itsthevine.web;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
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.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
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 admin, signed in and driven the way a browser drives it: form posts and redirects.
*
* <p>These replace the JSON admin's tests. The screen used to be React talking to {@code
* /api/admin/**}; it is now Thymeleaf forms, so what is worth asserting is that a form arrives bound
* correctly, that an edit lands, that a refusal comes back readable rather than as a stack trace, and
* that a structural button changes the draft without writing it.
*
* <p>Runs with {@code SECURITY_MODE=OIDC}, because that is the only condition under which the admin
* exists at all.
*/
@SpringBootTest(properties = {
"SECURITY_MODE=OIDC",
// Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the
// discovery document at startup — that needs the network and a real identity provider.
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
"spring.security.oauth2.client.registration.authentik.client-id=test",
"spring.security.oauth2.client.registration.authentik.client-secret=test",
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
"[email protected]",
"[email protected]",
"platform.storage.access-key=test",
"platform.storage.secret-key=test"
})
@Testcontainers
// Rolled back per test, so each starts from the seeded catalogue. MockMvc runs the controller on this
// thread, which is what lets the test's transaction wrap the whole request.
@Transactional
class AdminPagesTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@Autowired
WebApplicationContext context;
MockMvc mvc;
@BeforeEach
void setUp() {
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out.
mvc = MockMvcBuilders.webAppContextSetup(context)
.apply(SecurityMockMvcConfigurers.springSecurity())
.build();
}
@Test
void theCatalogueScreenShowsWhatIsOnThePageWithItsPhotos() throws Exception {
mvc.perform(get("/admin").with(user("morissa")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("76th Birthday Cake")))
// The photo URL and the key beside it: the key is what the arrange buttons name it by.
.andExpect(content().string(containsString("products/76th_birthday_cake.webp")))
.andExpect(content().string(containsString("On the page (40)")))
// The filter list, with the count that decides whether Delete is offered.
.andExpect(content().string(containsString("Cookies")));
}
@Test
void renamingAnItemLandsAndSaysSo() throws Exception {
mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf())
.param("do", "save")
.param("name", "76th Birthday Cake (chocolate)")
.param("category", "Cakes"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin"))
.andExpect(flash().attribute("done", containsString("Saved")));
mvc.perform(get("/admin").with(user("morissa")))
.andExpect(content().string(containsString("76th Birthday Cake (chocolate)")));
}
@Test
void aRefusalComesBackAsASentenceTheEditorCanActOn() throws Exception {
// Cookies has items filed under it, and deleting the button shouldn't decide what happens to them.
mvc.perform(post("/admin/categories/1").with(user("morissa")).with(csrf()).param("do", "delete"))
.andExpect(redirectedUrl("/admin"))
.andExpect(flash().attribute("problem", containsString("still filed under Cookies")));
}
@Test
void movingAnItemUpFromTheTopIsNotAnError() throws Exception {
// The button is disabled in the page, but a stale page could still post this.
mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf()).param("do", "move:-1"))
.andExpect(redirectedUrl("/admin"))
.andExpect(flash().attributeCount(0));
}
@Test
void theTableEditorRendersTheStoredTableAsAForm() throws Exception {
mvc.perform(get("/admin/catering/tables/1").with(user("morissa")))
.andExpect(status().isOk())
// Indexed names are what let Spring bind the grid back into the right cells.
.andExpect(content().string(containsString("name=\"columns[0].label\"")))
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
.andExpect(content().string(containsString("value=\"18 items\"")))
// The price round-trips as text: it came out "$24" and goes back the same way.
.andExpect(content().string(containsString("value=\"$24\"")));
}
@Test
void savingTheTableWritesEveryCell() throws Exception {
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
.param("do", "save")
.param("name", "Office boxes")
.param("blurb", "For meetings.")
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$26")
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
.param("lines[0].values[0]", "14 items")
.param("notes[0]", "Two days' notice, please."))
.andExpect(redirectedUrl("/admin/catering"))
.andExpect(flash().attribute("done", containsString("Saved the Office boxes table")));
mvc.perform(get("/api/catering"))
.andExpect(content().string(containsString("Office boxes")))
.andExpect(content().string(containsString("$26")))
.andExpect(content().string(containsString("14 items")));
}
@Test
void addingAColumnKeepsWhatWasTypedAndWritesNothing() throws Exception {
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
.param("do", "add-column")
.param("name", "Office")
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
// A cell edited but not yet saved: it has to survive the round trip.
.param("lines[0].values[0]", "13 items"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("value=\"13 items\"")))
// The new column exists in the form, and every line grew an entry to match it.
.andExpect(content().string(containsString("name=\"columns[1].label\"")))
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
.andExpect(content().string(containsString("Not saved yet")));
// And nothing was written: the live page still says what it said.
mvc.perform(get("/api/catering"))
.andExpect(content().string(containsString("12 items")));
}
@Test
void removingAColumnTakesItsCellsOutOfEveryLine() throws Exception {
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
.param("do", "remove-column:0")
.param("name", "Office")
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
.param("columns[1].id", "2").param("columns[1].label", "Medium").param("columns[1].price", "$32")
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
.param("lines[0].values[0]", "12 items")
.param("lines[0].values[1]", "18 items"))
.andExpect(status().isOk())
// What is left is the Medium column and, under it, Medium's entry — not Small's.
.andExpect(content().string(containsString("value=\"Medium\"")))
.andExpect(content().string(containsString("value=\"18 items\"")))
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("value=\"12 items\""))))
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("name=\"columns[1].label\""))));
}
@Test
void aPriceThatIsntAPriceComesBackWithTheWorkStillInTheForm() throws Exception {
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
.param("do", "save")
.param("name", "Office")
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "ask us")
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
.param("lines[0].values[0]", "12 items"))
// Not a redirect: a redirect would throw the work away and leave them guessing.
.andExpect(status().isOk())
.andExpect(content().string(containsString("isn&#39;t a price")))
.andExpect(content().string(containsString("value=\"ask us\"")));
}
@Test
void thePageNotesAreReplacedByWhatTheFormSubmits() throws Exception {
mvc.perform(post("/admin/catering/notes").with(user("morissa")).with(csrf())
.param("notes", "Prices may change.")
.param("notes", " ")
.param("notes", "Two weeks' notice for a wedding."))
.andExpect(redirectedUrl("/admin/catering"));
mvc.perform(get("/api/catering"))
.andExpect(content().string(containsString("Two weeks' notice for a wedding.")))
// The blank box was a deletion, not a note: two notes came back, not three.
.andExpect(content().string(containsString("Prices may change.")))
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("\" \""))));
}
}
@@ -69,13 +69,25 @@ class AdminSecurityTest {
}
@Test
void everyAdminApiIsClosedToAnonymousVisitors() throws Exception {
// csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"x\"}"))
void everyAdminWriteIsClosedToAnonymousVisitors() throws Exception {
// csrf() on the writes, so these assert AUTHORIZATION, not a missing token. The admin is pages
// and form posts now, so this is the whole surface — there is no JSON admin left to guard.
mvc.perform(get("/admin")).andExpect(status().isUnauthorized());
mvc.perform(get("/admin/catering")).andExpect(status().isUnauthorized());
mvc.perform(get("/admin/catering/tables/1")).andExpect(status().isUnauthorized());
mvc.perform(post("/admin/items").with(csrf()).param("name", "Free cake").param("category", "Cakes"))
.andExpect(status().isUnauthorized());
mvc.perform(post("/admin/items/1").with(csrf()).param("do", "delete"))
.andExpect(status().isUnauthorized());
mvc.perform(post("/admin/categories").with(csrf()).param("name", "x"))
.andExpect(status().isUnauthorized());
// The prices are the one thing on this site a stranger would most enjoy editing.
mvc.perform(post("/admin/catering/tables/1").with(csrf())
.param("do", "save").param("name", "Free")
.param("columns[0].label", "Any").param("columns[0].price", "0")
.param("lines[0].label", "Everything").param("lines[0].values[0]", "yes"))
.andExpect(status().isUnauthorized());
mvc.perform(post("/admin/catering/notes").with(csrf()).param("notes", "anything"))
.andExpect(status().isUnauthorized());
}
@@ -83,10 +95,12 @@ class AdminSecurityTest {
void theAdminPageRedirectsABrowserToLogin() throws Exception {
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
// the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this
// must send a browser's Accept header to see the 302. (Verified against a running container.)
// the platform answers */* with a bare 401, which is why the checks above see 401 and this one
// has to send a browser's Accept header to see the 302. (Verified against a running container.)
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
.andExpect(status().is3xxRedirection());
mvc.perform(get("/admin/catering").header("Accept", "text/html,application/xhtml+xml"))
.andExpect(status().is3xxRedirection());
}
@Test
@@ -94,12 +108,14 @@ class AdminSecurityTest {
// Locking the admin must not lock the menu.
mvc.perform(get("/api/products")).andExpect(status().isOk());
mvc.perform(get("/api/categories")).andExpect(status().isOk());
mvc.perform(get("/api/catering")).andExpect(status().isOk());
}
@Test
void theContactFormStillNeedsItsCsrfToken() throws Exception {
// Enabling the security starter enables CSRF for the PUBLIC contact form too. Without the token
// it 403s; the SPA reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN.
// Enabling the security starter enables CSRF for the PUBLIC contact form too. Without a token it
// 403s. The page's own form carries a hidden field (Spring Security fills it in for any th:action
// form); this JSON endpoint needs the X-XSRF-TOKEN header from the cookie.
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Ada\",\"email\":\"[email protected]\",\"message\":\"hi\"}"))
.andExpect(status().isForbidden());
@@ -0,0 +1,360 @@
package com.itsthevine.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import com.itsthevine.web.domain.Money;
import jakarta.persistence.EntityManager;
/**
* The catering tables, against the real seeded spreadsheet — so the migration is covered too.
*
* <p>Every test that writes runs inside the test's own transaction and is rolled back, so the seeded
* page is the same for each one.
*/
@SpringBootTest
@Testcontainers
@Transactional
class CateringMenuTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
// The contact starter refuses to start on a blank recipient, and this app has a
// ContactController, so the context needs one even to test the catering page.
registry.add("platform.contact.to", () -> "[email protected]");
registry.add("platform.contact.from", () -> "[email protected]");
registry.add("platform.storage.access-key", () -> "test");
registry.add("platform.storage.secret-key", () -> "test");
}
@Autowired
CateringMenu catering;
@Autowired
EntityManager entityManager;
@Test
void carriesTheBakerysSpreadsheetIntoTheDatabase() {
// The prices, the sizes and the shape are the spreadsheet's (V4). The wording is not: V6 rewrote
// the cells as sentences a customer can read, since "6+6" and "4 dz" were notes to the baker.
List<CateringMenu.PackageView> tables = catering.menu().packages();
assertThat(tables).extracting(CateringMenu.PackageView::name)
.containsExactly("Office boxes", "Parties", "Weddings");
CateringMenu.PackageView office = tables.get(0);
assertThat(office.tiers()).extracting(CateringMenu.TierView::label)
.containsExactly("Small", "Medium", "Large");
assertThat(office.tiers()).extracting(CateringMenu.TierView::price)
.containsExactly("$24", "$32", "$40");
// One line, not three: a Small box is twelve items mixed in sixes, not twelve of each thing. The
// choice is written into the line's name, which is the same rule the other two tables follow.
assertThat(office.rows()).extracting(CateringMenu.RowView::label)
.containsExactly("Any mix of mini muffins, mini scones and mini cinnamon rolls");
assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items");
// "Small" says nothing about how many it feeds; this does.
assertThat(office.tiers()).extracting(CateringMenu.TierView::serves)
.containsExactly("About 68 people", "About 1012 people", "About 1520 people");
// The party and wedding columns are named by head count already, so they don't repeat it.
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::serves).containsOnlyNulls();
assertThat(office.blurb()).contains("morning meeting");
assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("Baked in sixes"));
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label)
.containsExactly("1520 people", "2030 people", "3040 people");
assertThat(tables.get(2).tiers()).extracting(CateringMenu.TierView::price)
.containsExactly("$236", "$310", "$386");
// Wedding delivery terms belong to the wedding table, not to the page.
assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee"));
// No cell anywhere still speaks in shorthand.
assertThat(tables).allSatisfy(table -> assertThat(table.rows()).allSatisfy(row ->
assertThat(row.values()).noneMatch(value -> value.matches(".*\\b(dz|B&G|in cake)\\b.*"))));
}
@Test
void theWeddingLinesEachSaySomething() {
// The source spreadsheet is offset — the prices sit on its "cupcakes" line — and V4 carried that
// over literally, which left two lines with no quantity in any column: a row of dashes on the
// page. V5 reads it as a baker would (a 12x17 pan is the sheet pan; "cc or sc" is what the dozens
// count) and the guards in it mean an edit made in the admin since would have survived instead.
CateringMenu.PackageView weddings = catering.menu().packages().get(2);
assertThat(weddings.rows()).extracting(CateringMenu.RowView::label)
.containsExactly("Bride & groom cake", "Sheet cakes or 12×17 bars",
"Cupcakes or sugar cookies");
assertThat(weddings.rows().get(1).values()).containsExactly("Two pans", "Three pans", "Four pans");
assertThat(weddings.rows().get(2).values()).containsExactly("Four dozen", "Five dozen", "Six dozen");
// Nothing left that is blank the whole way across.
assertThat(weddings.rows())
.noneMatch(row -> row.values().stream().allMatch(String::isEmpty));
}
@Test
void thePartyLinesEachSaySomethingToo() {
// Same offset as the wedding table: the quantities on "Cupcakes" were always
// cupcakes-or-sugar-cookies, with an empty "Sugar cookies" line beneath.
CateringMenu.PackageView parties = catering.menu().packages().get(1);
assertThat(parties.rows()).extracting(CateringMenu.RowView::label)
.containsExactly("Cake", "Cupcakes or sugar cookies");
assertThat(parties.rows().get(0).values())
.containsExactly("A 6-inch cake", "An 8-inch cake", "A 10-inch cake");
assertThat(parties.rows())
.noneMatch(row -> row.values().stream().allMatch(String::isEmpty));
}
@Test
void everyLineCarriesOneEntryPerColumn() {
// The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is
// advertised at another box's price.
assertThat(catering.everything().packages()).allSatisfy(table ->
assertThat(table.rows()).allSatisfy(row ->
assertThat(row.values()).hasSameSizeAs(table.tiers())));
}
@Test
void keepsBlankCellsRatherThanCollapsingThem() {
// No seeded line is blank across any more, so this uses a table of its own making: a blank cell
// still has to survive a save, because dropping one would shorten the line and shift the rest.
CateringMenu.PackageView fresh = catering.add("Trays");
catering.save(fresh.id(), new CateringMenu.PackageEdit("Trays", null,
List.of(new CateringMenu.TierEdit(null, "Small", "10", null),
new CateringMenu.TierEdit(null, "Large", "20", null)),
List.of(new CateringMenu.RowEdit(null, "Seasonal extras", List.of("", "A dozen"))),
List.of()));
CateringMenu.RowView row = catering.everything().packages().getLast().rows().getFirst();
assertThat(row.values()).containsExactly("", "A dozen");
}
@Test
void statesThePageWideTermsSeparatelyFromAnyOneTable() {
assertThat(catering.menu().notes()).hasSize(2);
assertThat(catering.menu().notes().get(0)).contains("price may change");
}
@Test
void writesMoneyTheWayAPriceListDoes() {
assertThat(Money.format(2400)).isEqualTo("$24");
assertThat(Money.format(23600)).isEqualTo("$236");
assertThat(Money.format(2450)).isEqualTo("$24.50");
assertThat(Money.format(2405)).isEqualTo("$24.05");
assertThat(Money.format(150000)).isEqualTo("$1,500");
// "Ask us" is a legitimate price. It must not render as "$0".
assertThat(Money.format(null)).isNull();
}
@Test
void takesAPriceHoweverTheBakeryTypesIt() {
assertThat(Money.cents("24")).isEqualTo(2400);
assertThat(Money.cents("$24")).isEqualTo(2400);
assertThat(Money.cents(" $1,250.00 ")).isEqualTo(125000);
// The float route gives 2410.0000000000005 for this one.
assertThat(Money.cents("24.10")).isEqualTo(2410);
assertThat(Money.cents("")).isNull();
assertThat(Money.cents(null)).isNull();
assertThatThrownBy(() -> Money.cents("ask us"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("isn't a price");
assertThatThrownBy(() -> Money.cents("24.005"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("go to the cent");
}
@Test
void droppingAColumnTakesItsValuesWithIt() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
Long medium = office.tiers().get(1).id();
catering.save(office.id(), withoutColumn(office, 1));
// Straight back to the database: the point of this test is the rows that were deleted, and a
// session cache would happily show the right answer without them having been.
entityManager.flush();
entityManager.clear();
CateringMenu.PackageView saved = catering.everything().packages().get(0);
assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large");
assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium);
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "24 items");
assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2));
}
@Test
void refusesATableWhoseLinesAndColumnsDisagree() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
// A column removed but the values left alone — the mistake that would shift every price.
CateringMenu.PackageEdit half = new CateringMenu.PackageEdit(
office.name(), office.blurb(),
asEdits(office).subList(0, 2),
asLines(office),
office.notes());
assertThatThrownBy(() -> catering.save(office.id(), half))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Any mix of mini muffins")
.hasMessageContaining("3 entries but the table has 2 columns");
}
@Test
void editingATableKeepsTheLinesItAlreadyHad() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
Long muffins = office.rows().get(0).id();
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(office));
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("12 items", "20 items", "24 items")));
catering.save(office.id(), new CateringMenu.PackageEdit(
"Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes()));
CateringMenu.PackageView saved = catering.everything().packages().get(0);
assertThat(saved.name()).isEqualTo("Office boxes");
assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings.");
// Same line, edited — not a new line that happens to read the same.
assertThat(saved.rows().get(0).id()).isEqualTo(muffins);
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "20 items", "24 items");
}
@Test
void aNewTableStaysOffThePublicPageUntilItSaysSomething() {
CateringMenu.PackageView fresh = catering.add("Holiday boxes");
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
.containsExactly("Office boxes", "Parties", "Weddings", "Holiday boxes");
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
.doesNotContain("Holiday boxes");
// Once it has a column and a line, it's a price table and it belongs on the page.
catering.save(fresh.id(), new CateringMenu.PackageEdit("Holiday boxes", null,
List.of(new CateringMenu.TierEdit(null, "Dozen", "18", "About 6 people")),
List.of(new CateringMenu.RowEdit(null, "Frosted cut-outs", List.of("12 items"))),
List.of()));
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
.contains("Holiday boxes");
}
@Test
void deletingATableTakesItsColumnsLinesAndCellsWithIt() {
CateringMenu.PackageView weddings = catering.everything().packages().get(2);
catering.remove(weddings.id());
// Flushed on purpose: a delete that leaves its children behind fails against the real foreign
// keys, not in memory, and this one is a button on the admin screen.
entityManager.flush();
entityManager.clear();
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
.containsExactly("Office boxes", "Parties");
// The page's own terms outlive any one table.
assertThat(catering.menu().notes()).hasSize(2);
}
@Test
void putsTheTablesWhereTheEditorLeftThem() {
List<CateringMenu.PackageView> tables = catering.everything().packages();
List<Long> weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id());
assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name)
.containsExactly("Weddings", "Office boxes", "Parties");
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
.containsExactly("Weddings", "Office boxes", "Parties");
}
@Test
void refusesPricesAndHeadingsThatCantBeRight() {
CateringMenu.PackageView office = catering.everything().packages().get(0);
assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("less than nothing");
assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "$50,000")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("$10,000");
assertThatThrownBy(() -> catering.save(office.id(), withColumnLabel(office, " ")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Every column needs a heading");
assertThatThrownBy(() -> catering.save(office.id(), withCell(office, "x".repeat(301))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("300 characters");
}
@Test
void replacingThePageNotesIsTheWholeList() {
assertThat(catering.replaceNotes(List.of("One term.", " ", "Another term.")))
// A blank line in the editor is not a note.
.containsExactly("One term.", "Another term.");
assertThat(catering.menu().notes()).containsExactly("One term.", "Another term.");
assertThat(catering.replaceNotes(List.of("Only this one now."))).hasSize(1);
entityManager.flush();
entityManager.clear();
assertThat(catering.menu().notes()).containsExactly("Only this one now.");
}
// --- turning what was read back into what the editor would send ---------------------------
/** Note the round trip: what came back as "$24" goes out again as "$24" and must still mean 2400. */
private static List<CateringMenu.TierEdit> asEdits(CateringMenu.PackageView table) {
return table.tiers().stream()
.map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price(), t.serves()))
.toList();
}
private static List<CateringMenu.RowEdit> asLines(CateringMenu.PackageView table) {
return table.rows().stream()
.map(r -> new CateringMenu.RowEdit(r.id(), r.label(), r.values()))
.toList();
}
/** The table with one column gone, and every line's values narrowed to match — as the screen sends it. */
private static CateringMenu.PackageEdit withoutColumn(CateringMenu.PackageView table, int column) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.remove(column);
List<CateringMenu.RowEdit> rows = table.rows().stream().map(row -> {
List<String> values = new ArrayList<>(row.values());
values.remove(column);
return new CateringMenu.RowEdit(row.id(), row.label(), values);
}).toList();
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, rows, table.notes());
}
private static CateringMenu.PackageEdit withColumnPrice(CateringMenu.PackageView table, String price) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price, null));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
}
private static CateringMenu.PackageEdit withColumnLabel(CateringMenu.PackageView table, String label) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price(), null));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
}
private static CateringMenu.PackageEdit withCell(CateringMenu.PackageView table, String value) {
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(table));
List<String> values = new ArrayList<>(rows.get(0).values());
values.set(0, value);
rows.set(0, new CateringMenu.RowEdit(rows.get(0).id(), rows.get(0).label(), values));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), asEdits(table), rows, table.notes());
}
}
@@ -0,0 +1,71 @@
package com.itsthevine.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.Test;
/**
* The opening times, as a list and as an answer.
*
* <p>No Spring here on purpose: this is a calculation over a constant, and it should be testable at the
* speed of a calculation. Every "now" is passed in, so the Sunday-afternoon case doesn't need a Sunday.
*/
class HoursTest {
private final Hours hours = new Hours();
@Test
void collapsesTheWeekTheWayASignWouldWriteIt() {
assertThat(hours.week()).extracting(Hours.Span::days, Hours.Span::hours)
.containsExactly(
org.assertj.core.groups.Tuple.tuple("Tuesday Friday", "7:00am 2:00pm"),
org.assertj.core.groups.Tuple.tuple("Saturday", "7:00am 12:00pm"),
org.assertj.core.groups.Tuple.tuple("Sunday Monday", "Closed"));
}
@Test
void theWeekStartsOnTheDayTheShopsWeekStarts() {
// Not Monday: the shop is shut on Monday, and a list that opens with a closed day reads as an
// apology. This is the order the printed list has always used.
assertThat(hours.week().getFirst().days()).startsWith("Tuesday");
assertThat(hours.week().getLast().closed()).isTrue();
}
@Test
void saysHowLongIsLeftWhenTheShopIsOpen() {
Hours.Status wednesdayMorning = hours.at(chicago("2026-07-29T09:15"));
assertThat(wednesdayMorning.open()).isTrue();
assertThat(wednesdayMorning.summary()).isEqualTo("Open until 2:00pm");
}
@Test
void countsTheMinuteOfOpeningAsOpenAndTheMinuteOfClosingAsShut() {
// The two edges anyone would get wrong, and the ones a customer stands at the door for.
assertThat(hours.at(chicago("2026-07-29T07:00")).open()).isTrue();
assertThat(hours.at(chicago("2026-07-29T13:59")).open()).isTrue();
assertThat(hours.at(chicago("2026-07-29T14:00")).open()).isFalse();
assertThat(hours.at(chicago("2026-07-29T06:59")).open()).isFalse();
}
@Test
void tellsYouWhenItOpensAgainRatherThanJustSayingClosed() {
// Saturday afternoon, shut since noon: the next open day is Tuesday, three days away.
Hours.Status saturdayAfternoon = hours.at(chicago("2026-08-01T15:00"));
assertThat(saturdayAfternoon.open()).isFalse();
assertThat(saturdayAfternoon.summary()).isEqualTo("Closed · opens Tuesday at 7:00am");
// Monday: tomorrow reads better than naming the day.
assertThat(hours.at(chicago("2026-08-03T10:00")).summary())
.isEqualTo("Closed · opens tomorrow at 7:00am");
// Before opening on a day it does open, the day itself is redundant.
assertThat(hours.at(chicago("2026-07-29T06:30")).summary()).isEqualTo("Opens at 7:00am");
}
private static ZonedDateTime chicago(String localDateTime) {
return LocalDateTime.parse(localDateTime).atZone(Shop.ZONE);
}
}
@@ -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,40 @@
package com.itsthevine.web;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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. There is no SPA here at all now — no shell to forward to — so
* that assertion describes an app this no longer is. The contract's own test methods are package-private,
* so it 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 +44,58 @@ 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("routing is server-side: an unknown path is a 404, and so is /admin with no identity provider")
void routingIsServerSide() throws Exception {
mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound());
// No SECURITY_MODE here, so AdminController does not exist — "no Authentik configured" means "no
// admin", and it 404s like any other unknown path rather than exposing catalogue writes.
// AdminSecurityTest covers the other half: with OIDC on, /admin exists and needs a login.
mvc.perform(get("/admin")).andExpect(status().isNotFound());
}
}
@@ -0,0 +1,185 @@
package com.itsthevine.web;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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;
@Autowired
com.itsthevine.web.domain.ContactEnquiryRepository enquiries;
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 &amp; 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 boxes")))
.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, in the wording a customer reads rather than the spreadsheet's.
// The office box is a total mixed in sixes, not three separate things.
.andExpect(content().string(containsString("Any mix of mini muffins")))
.andExpect(content().string(containsString("18 items")))
.andExpect(content().string(containsString("About 68 people")))
.andExpect(content().string(containsString("Baked in sixes")))
// One card per size, and one enquiry link per table — not one per card, which would have
// read "Ask about the 1520 people".
// The size label is in the HTML as written; the small caps are CSS.
.andExpect(content().string(containsString("Large")))
.andExpect(content().string(containsString("Ask about office boxes")))
// The price grid is gone: it was the source spreadsheet, rendered.
.andExpect(content().string(not(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 theNavMarksThePageYouAreOn() throws Exception {
// Generated from the path the controller set, so a wrong model attribute would mark nothing at
// all — and nothing at all looks exactly like a page that simply has no active link.
mvc.perform(get("/catering"))
.andExpect(content().string(containsString("href=\"/catering\" aria-current=\"page\"")));
mvc.perform(get("/products"))
.andExpect(content().string(containsString("href=\"/products\" aria-current=\"page\"")))
.andExpect(content().string(not(containsString("href=\"/catering\" aria-current"))));
}
@Test
void everyPageOffersTheKeyboardAWayPastTheNav() throws Exception {
mvc.perform(get("/"))
.andExpect(content().string(containsString("href=\"#content\"")))
.andExpect(content().string(containsString("id=\"content\"")));
}
@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 aFilledInTrapIsThankedAndThrownAway() throws Exception {
// The bot is told the same thing a person is told — anything else is a training signal — and
// nothing is recorded or sent. The enquiry table is what proves the second half.
mvc.perform(post("/contact")
.param("name", "Bot").param("email", "[email protected]")
.param("message", "Cheap watches").param("website", "http://example.com"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Your message is on its way")));
assertThat(enquiries.count()).isZero();
}
@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());
}
}