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