Catering, and a pure Java/Spring site: Thymeleaf front to back #10
@@ -40,7 +40,26 @@ jobs:
|
|||||||
mvn -B -ntp -s .gitea/ci-settings.xml -DskipFrontend=true verify
|
mvn -B -ntp -s .gitea/ci-settings.xml -DskipFrontend=true verify
|
||||||
|
|
||||||
# Build + Trivy on every run (PRs included), so a broken Dockerfile or a new HIGH/CRITICAL CVE
|
# Build + Trivy on every run (PRs included), so a broken Dockerfile or a new HIGH/CRITICAL CVE
|
||||||
# blocks the merge. Only the push is main-only.
|
# blocks the merge.
|
||||||
|
#
|
||||||
|
# A PULL REQUEST MUST NOT TOUCH THE :latest TAG. This runner builds on the host's Docker daemon —
|
||||||
|
# the same daemon the live container runs on — so retagging :latest IS a deployment: Watchtower
|
||||||
|
# compares the running container's image against :latest, finds they differ, and recreates the
|
||||||
|
# container from the locally built image. Gating only the push was not enough; on 2026-07-26 the PR
|
||||||
|
# builds for this branch deployed unmerged code to itsthevine.com several times over, and each time
|
||||||
|
# Watchtower's next pull of the (older) registry :latest reverted it. One of those deploys ran a
|
||||||
|
# migration against the production database.
|
||||||
|
#
|
||||||
|
# So a PR builds pr-<number> instead, which nothing watches. Trivy scans whatever was built, and the
|
||||||
|
# push step below still only runs off a PR.
|
||||||
|
- name: Choose the image tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
echo "IMAGE_TAG=pr-${{ github.event.number }}" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "IMAGE_TAG=latest" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
@@ -51,17 +70,18 @@ jobs:
|
|||||||
--secret id=maven_user,env=MAVEN_USER \
|
--secret id=maven_user,env=MAVEN_USER \
|
||||||
--secret id=maven_token,env=MAVEN_TOKEN \
|
--secret id=maven_token,env=MAVEN_TOKEN \
|
||||||
--build-arg GIT_SHA=${{ github.sha }} \
|
--build-arg GIT_SHA=${{ github.sha }} \
|
||||||
-t git.thebennett.net/thevine/itsthevine:latest -t git.thebennett.net/thevine/itsthevine:${{ github.sha }} .
|
-t "git.thebennett.net/thevine/itsthevine:$IMAGE_TAG" \
|
||||||
|
-t "git.thebennett.net/thevine/itsthevine:${{ github.sha }}" .
|
||||||
|
|
||||||
- name: Scan image (Trivy)
|
- name: Scan image (Trivy)
|
||||||
run: |
|
run: |
|
||||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \
|
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \
|
||||||
git.thebennett.net/thevine/itsthevine:latest || true
|
"git.thebennett.net/thevine/itsthevine:$IMAGE_TAG" || true
|
||||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
|
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
|
||||||
--pkg-types library --exit-code 1 --no-progress \
|
--pkg-types library --exit-code 1 --no-progress \
|
||||||
git.thebennett.net/thevine/itsthevine:latest
|
"git.thebennett.net/thevine/itsthevine:$IMAGE_TAG"
|
||||||
|
|
||||||
# Publish only on a real push to main (or manual dispatch) — never from a pull request.
|
# Publish only on a real push to main (or manual dispatch) — never from a pull request.
|
||||||
- name: Push image
|
- name: Push image
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
target/
|
target/
|
||||||
frontend/node_modules/
|
# The Tailwind CLI's dependencies. The compiled stylesheet needs no rule of its own: it is written
|
||||||
frontend/dist/
|
# straight into target/classes/static/css, which is already ignored above.
|
||||||
|
src/main/styles/node_modules/
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -36,5 +36,8 @@ ARG GIT_SHA=unknown
|
|||||||
LABEL org.opencontainers.image.title="itsthevine" \
|
LABEL org.opencontainers.image.title="itsthevine" \
|
||||||
org.opencontainers.image.source="https://git.thebennett.net/thevine/itsthevine" \
|
org.opencontainers.image.source="https://git.thebennett.net/thevine/itsthevine" \
|
||||||
org.opencontainers.image.revision="${GIT_SHA}"
|
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"]
|
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
|
||||||
|
|||||||
@@ -1,36 +1,112 @@
|
|||||||
# The Vine Coffeehouse + Bakery — itsthevine.com
|
# The Vine Coffeehouse + Bakery — itsthevine.com
|
||||||
|
|
||||||
Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot serving a Vite/React SPA,
|
Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot rendering its own pages with
|
||||||
on [the Bennett platform](https://git.thebennett.net/austin/platform).
|
Thymeleaf, on [the Bennett platform](https://git.thebennett.net/austin/platform).
|
||||||
|
|
||||||
Previously a Next.js app on Cloudflare, then self-hosted; the look is unchanged.
|
Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered end to end —
|
||||||
|
there is no JavaScript framework in this repo. The look has not changed through any of it.
|
||||||
|
|
||||||
## Shape
|
## Shape
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
|
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
|
||||||
| Frontend | Vite + React 19 + TypeScript + Tailwind v4, served from the jar |
|
| Pages | Thymeleaf, `src/main/resources/templates` — **no JavaScript** except one 100-line file for the product-card arrows |
|
||||||
|
| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css`. `src/main/styles` is the whole asset pipeline |
|
||||||
|
| Admin | Thymeleaf forms at `/admin`, behind Authentik |
|
||||||
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
|
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
|
||||||
| Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image |
|
| Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image |
|
||||||
| Deploy | Gitea CI → image → Watchtower → Caddy |
|
| Deploy | Gitea CI → image → Watchtower → Caddy |
|
||||||
|
|
||||||
|
### The look, in one place
|
||||||
|
|
||||||
|
`src/main/styles` is the whole of it — no bundler, no framework, one stylesheet:
|
||||||
|
|
||||||
|
| file | what it holds |
|
||||||
|
|---|---|
|
||||||
|
| `theme.css` | the three typefaces and the sage-and-cream palette. Names, nothing drawn |
|
||||||
|
| `base.css` | bare elements: page background, body type, the fade-in, the focus ring, the z-index scale |
|
||||||
|
| `type.css` | the typographic ladder (below) |
|
||||||
|
| `components.css` | `panel`, `pill-*`, `chip`, `section`/`band` — the site's own classes |
|
||||||
|
| `admin.css` | the admin's controls, as `@utility` so `file:` variants work on the photo pickers |
|
||||||
|
|
||||||
|
**The type ladder comes out of the logo.** The lockup is "The Vine" in LeJour Script over COFFEEHOUSE +
|
||||||
|
BAKERY in AdBhashitha, letterspaced — and everything below is that idea made progressively more readable:
|
||||||
|
the script stays in the wordmark and nowhere else; AdBhashitha carries `h1`–`h4` and `price`, keeping the
|
||||||
|
wordmark's 0.01em letter-spacing so a heading sits on the same rhythm as the logo above it; `eyebrow` and
|
||||||
|
`label` are the hinge, sans but letterspaced like the tagline; then plain Raleway for anything you have to
|
||||||
|
read a paragraph of. None of the type classes set a colour — the same heading appears in sage on cream and
|
||||||
|
cream on sage.
|
||||||
|
|
||||||
|
Write those class names in templates rather than the utilities behind them. The full vocabulary:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| surfaces | `panel`, `panel-lift`, `panel-head`, `panel-mark`, `photo` |
|
||||||
|
| buttons | `pill-sage`, `pill-cream`, `pill-outline`, `pill-ghost`, `chip`/`chip-on` |
|
||||||
|
| links | `link`, `link-plain`, `link-on-dark` |
|
||||||
|
| type | `h1`–`h4`, `price`, `eyebrow`, `label`, `lede`, `wordmark` |
|
||||||
|
| layout | `container`, `measure`, `measure-wide`, `section`, `band`, `page-head` |
|
||||||
|
| forms | `field` — one input for the public form and the admin |
|
||||||
|
|
||||||
|
Four colourways of one button exist because the site puts buttons on cream *and* on sage. Corners come
|
||||||
|
from `--radius-panel` (12px) and `--radius-field` (8px), so "how round is a card" is one decision.
|
||||||
|
|
||||||
|
If you find yourself writing `bg-white rounded-… shadow-…` or `underline underline-offset-4` in a
|
||||||
|
template, there is already a name for it.
|
||||||
|
|
||||||
|
### Why server-rendered
|
||||||
|
|
||||||
|
The pages are content: a menu, a story, opening hours, a price list. Rendering them in the browser meant
|
||||||
|
shipping a router and a component tree to show them, and it meant `PageMetaController` — a class whose
|
||||||
|
only job was to splice per-page `<title>` and OG tags into one shell with regular expressions, because a
|
||||||
|
crawler or a link-preview scraper got nothing useful otherwise. A page that is rendered on the server
|
||||||
|
writes its own head, so that whole mechanism is deleted rather than ported. The category filter is a
|
||||||
|
`?category=` link instead of a click handler, which also makes every filtered view a URL you can send
|
||||||
|
someone, and the contact form is a form post.
|
||||||
|
|
||||||
|
`platform.web.spa.enabled=false` follows from that: the platform's fallback forwards extension-less paths
|
||||||
|
to `/index.html` so a React SPA can own routing, and there is no SPA here — leaving it on would answer a
|
||||||
|
mistyped URL with a blank page and a 200 instead of the site's own 404.
|
||||||
|
|
||||||
## What the server owns
|
## 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
|
- **`/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
|
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`.
|
(`V2__products.sql`) read through `ProductCatalog`.
|
||||||
|
- **`/catering`** — the goodie box and catering page. Each table is rendered twice from the same model
|
||||||
|
and CSS shows one: a real `<table>` on a wide screen, because that is what a price list is and a screen
|
||||||
|
reader then announces the size and the item together; stacked cards on a phone, because a four-column
|
||||||
|
price table there is either illegible or a sideways scroll, and this page is mostly read on phones.
|
||||||
|
- **`/api/catering`** — the same tables as JSON: the
|
||||||
|
columns, the prices already written the way they should be read, the entries under each column, and
|
||||||
|
the small print. These came from the bakery as a spreadsheet and are stored as one (`V4__catering.sql`,
|
||||||
|
read through `CateringMenu`) rather than as markup, because the prices move and the last line of that
|
||||||
|
spreadsheet says the tables are "mostly just an idea for people". `Money` is the only thing that
|
||||||
|
decides what a typed price means or how it prints. A table with no columns or no lines is left off the
|
||||||
|
public response — adding a table and filling it in are two separate acts in the admin, and the gap
|
||||||
|
between them shouldn't put a bare heading on the live page.
|
||||||
|
- **`/contact`** — the form posts here and gets a page back. It renders rather than redirects on failure,
|
||||||
|
so a refused relay comes back with what the visitor typed still in the boxes: they wrote it once, and
|
||||||
|
the failure is ours. `/api/contact` still exists and answers JSON; both go through `Enquiries`, so
|
||||||
|
there is one order of operations for taking an enquiry.
|
||||||
- **`/api/contact`** — validates, **records the enquiry**, emails it, then fans out to the n8n hub.
|
- **`/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
|
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`,
|
ones are `enquiry.delivered = false`. Validation and delivery come from `platform-starter-contact`,
|
||||||
shared with the other sites.
|
shared with the other sites.
|
||||||
- **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used
|
- **Per-page metadata** — each route states its own title and description in `SiteController`, next to
|
||||||
to server-render these; a plain SPA would hand crawlers and link-preview scrapers one generic shell.
|
the handler that serves it, and `fragments/head.html` lays them out. `SiteControllerTest` asserts the
|
||||||
|
real `<title>` of every page.
|
||||||
|
|
||||||
## /admin
|
## /admin
|
||||||
|
|
||||||
|
Forms and redirects. Every write is a POST followed by a redirect back to the page, so the back button
|
||||||
|
and reload do what they look like they do, a double-tap can't repeat an upload, and there is no
|
||||||
|
client-side state to lose — a reload is always the truth. Two screens: `/admin` is the catalogue,
|
||||||
|
`/admin/catering` lists the price tables and `/admin/catering/tables/{id}` edits one.
|
||||||
|
|
||||||
The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or
|
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
|
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.
|
the person adding a cake is the person who baked it.
|
||||||
@@ -39,11 +115,31 @@ Photos are resized, stripped of EXIF, converted to webp and put in the bucket on
|
|||||||
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
|
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
|
||||||
webp or ship glibc natives that don't run on Alpine).
|
webp or ship glibc natives that don't run on Alpine).
|
||||||
|
|
||||||
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and
|
The catering tables are edited a table at a time rather than a field at a time. That isn't a taste in
|
||||||
`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
|
interfaces: a column heading, its price and the entries beneath it only mean anything together, so
|
||||||
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
|
`CateringPackage#arrange` takes the whole table and refuses one whose lines and columns disagree. Drop
|
||||||
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
|
the middle column on its own and every remaining entry shifts one place left — the Large box then
|
||||||
a bare 401 to handle.
|
advertises the Medium box's contents at the Large price, and nothing about the page looks broken.
|
||||||
|
|
||||||
|
**How that works without JavaScript.** One form holds the whole table and every button in it submits
|
||||||
|
that form; `name="do"` says which was pressed and its value carries the position it applies to
|
||||||
|
(`remove-column:2`). So "add a column" arrives with every cell the editor has typed, adds the column to
|
||||||
|
what arrived — plus an empty entry on every line — and re-renders. Nothing typed is lost, and **only
|
||||||
|
Save writes**: a half-built table with a blank column heading never reaches the live page, and the
|
||||||
|
aggregate would refuse it anyway. A failed save comes back the same way, with the work still in the
|
||||||
|
form and the reason above it, because a redirect would throw the work away and leave the editor guessing
|
||||||
|
which cell the message was about.
|
||||||
|
|
||||||
|
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminController` and `AdminCateringController` are
|
||||||
|
`@ConditionalOnProperty` on it, so a deployment that forgets to configure Authentik gets 404s rather than
|
||||||
|
catalogue writes open to the internet. `/admin/**` is an authenticated path, so a browser opening it is
|
||||||
|
sent to Authentik and comes back signed in. There is no JSON admin any more: `AdminProductController`,
|
||||||
|
`AdminCategoryController` and the old `/api/admin/**` endpoints existed for the React screen and went
|
||||||
|
with it. Their logic lives in `Catalogue` and `CateringMenu`, which the pages call.
|
||||||
|
|
||||||
|
**Testing a protected page needs `Accept: text/html`.** curl and MockMvc both send `*/*`, which the
|
||||||
|
platform answers with a bare 401; only a request that prefers HTML gets the 302 to Authentik. Asserting
|
||||||
|
the 401 and calling the page broken is a mistake worth not making twice.
|
||||||
|
|
||||||
Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the
|
Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the
|
||||||
bucket. Harmless — nothing links to it — but it accumulates.
|
bucket. Harmless — nothing links to it — but it accumulates.
|
||||||
@@ -57,14 +153,24 @@ history. EXIF (including GPS from phone photos) is stripped by the re-encode.
|
|||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# backend (needs Postgres on :5432 with an itsthevine database)
|
# the whole site, admin included (needs Postgres on :5432 with an itsthevine database)
|
||||||
mvn spring-boot:run
|
mvn spring-boot:run # http://localhost:8080
|
||||||
|
|
||||||
# frontend, proxies /api to :8080
|
# the stylesheet, while editing templates — watches and recompiles
|
||||||
cd frontend && npm install && npm run dev # http://localhost:2024
|
cd src/main/styles && npm install && npm run watch
|
||||||
```
|
```
|
||||||
|
|
||||||
Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`.
|
`/admin` only exists when `SECURITY_MODE=OIDC`, so a plain local run has the site and no admin. To work
|
||||||
|
on the admin without an identity provider, run with `SECURITY_MODE=OIDC`, dummy
|
||||||
|
`spring.security.oauth2.client.*` values (see `AdminPagesTest` for a set that starts without touching
|
||||||
|
the network) and `platform.security.authenticated-paths=/nothing/**` so nothing asks you to sign in.
|
||||||
|
|
||||||
|
`mvn spring-boot:run` compiles the stylesheet on the way (the Tailwind step is bound to
|
||||||
|
`process-classes` for exactly that reason). `-DskipFrontend=true` skips both frontend steps for a fast
|
||||||
|
backend loop — the pages then render **unstyled** until you build the CSS once.
|
||||||
|
|
||||||
|
Templates are cached by default, so a template edit needs a restart; add
|
||||||
|
`spring.thymeleaf.cache=false` to a local run if you are editing markup.
|
||||||
|
|
||||||
Tests need Docker (Testcontainers):
|
Tests need Docker (Testcontainers):
|
||||||
|
|
||||||
@@ -81,7 +187,8 @@ mvn verify
|
|||||||
| `CONTACT_TO` / `CONTACT_FROM` | enquiry recipient and envelope sender |
|
| `CONTACT_TO` / `CONTACT_FROM` | enquiry recipient and envelope sender |
|
||||||
| `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission |
|
| `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission |
|
||||||
| `SITE_BASE_URL` | absolute base for `og:url` |
|
| `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 |
|
| `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 |
|
| `STORAGE_ENDPOINT` / `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` / `STORAGE_BUCKET` | MinIO, for admin photo uploads. Blank endpoint leaves storage switched off |
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png">
|
|
||||||
<link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png">
|
|
||||||
<!-- Preconnect to the photo bucket so product images start loading a round-trip sooner. -->
|
|
||||||
<link rel="preconnect" href="https://s3.thebennett.net" crossorigin>
|
|
||||||
<!-- The wordmark is set in these; without preloading they arrive late and the logo visibly reflows. -->
|
|
||||||
<link rel="preload" as="font" type="font/woff2" href="/src/fonts/raleway-latin.woff2" crossorigin>
|
|
||||||
<link rel="preload" as="image" href="https://s3.thebennett.net/itsthevine/images/gallery/Outside.webp" fetchpriority="high">
|
|
||||||
<!-- PageMetaController rewrites the title and the four meta tags below per route, so crawlers and
|
|
||||||
link-preview scrapers get real per-page metadata instead of one generic shell. Keep the
|
|
||||||
attribute order and quoting as-is — it matches on them. -->
|
|
||||||
<title>The Vine Coffeehouse + Bakery</title>
|
|
||||||
<meta name="description" content="A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.">
|
|
||||||
<meta property="og:title" content="The Vine Coffeehouse + Bakery">
|
|
||||||
<meta property="og:description" content="A locally owned coffeehouse and bakery in downtown Princeville, IL.">
|
|
||||||
<meta property="og:url" content="https://itsthevine.com">
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:locale" content="en_US">
|
|
||||||
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "itsthevine-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc --noEmit && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"motion": "12.42.2",
|
|
||||||
"react": "^19.2.7",
|
|
||||||
"react-dom": "^19.2.7",
|
|
||||||
"react-router-dom": "7.18.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "4.3.3",
|
|
||||||
"@types/node": "^26.1.0",
|
|
||||||
"@types/react": "^19.2.17",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
|
||||||
"tailwindcss": "4.3.3",
|
|
||||||
"typescript": "^7.0.0",
|
|
||||||
"vite": "^8.1.3",
|
|
||||||
"vite-plugin-svgr": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
|
||||||
import Header from '@/components/Header';
|
|
||||||
import Footer from '@/components/Footer';
|
|
||||||
import HomePage from '@/pages/Home';
|
|
||||||
import ProductsPage from '@/pages/Products';
|
|
||||||
import HistoryPage from '@/pages/History';
|
|
||||||
import ContactPage from '@/pages/Contact';
|
|
||||||
import NotFoundPage from '@/pages/NotFound';
|
|
||||||
import AdminPage from '@/pages/Admin';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
|
|
||||||
* just opened. Anchors like /#visit still need to work, so only reset when there isn't one.
|
|
||||||
*/
|
|
||||||
const ScrollToTop = () => {
|
|
||||||
const { pathname, hash } = useLocation();
|
|
||||||
useEffect(() => {
|
|
||||||
// 'instant' overrides the page's scroll-behavior:smooth, which is meant for the #visit
|
|
||||||
// anchor, not for landing on a new page.
|
|
||||||
if (!hash) window.scrollTo({ top: 0, behavior: 'instant' });
|
|
||||||
}, [pathname, hash]);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The shop front: the nav, the footer, and the pages a customer sees. */
|
|
||||||
const PublicLayout = () => (
|
|
||||||
<div className="min-h-screen bg-bakery-50 flex flex-col">
|
|
||||||
<Header />
|
|
||||||
<main className="flex-grow">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The admin sits outside the public chrome deliberately. It isn't a page you'd browse to — the nav
|
|
||||||
* would offer a signed-in editor links away from unsaved work, and the opening hours in the footer
|
|
||||||
* are noise on a screen whose whole job is the catalogue.
|
|
||||||
*/
|
|
||||||
const AdminLayout = () => (
|
|
||||||
<div className="min-h-screen bg-bakery-50">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const App = () => (
|
|
||||||
<>
|
|
||||||
<ScrollToTop />
|
|
||||||
<Routes>
|
|
||||||
<Route element={<PublicLayout />}>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/products" element={<ProductsPage />} />
|
|
||||||
<Route path="/history" element={<HistoryPage />} />
|
|
||||||
<Route path="/contact" element={<ContactPage />} />
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route element={<AdminLayout />}>
|
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
/* Brand fonts ship with the app rather than coming from Google — same look, no third-party request
|
|
||||||
on every page load. Raleway is the variable latin subset. */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Raleway';
|
|
||||||
src: url('./fonts/raleway-latin.woff2') format('woff2');
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'AdBhashitha';
|
|
||||||
src: url('./fonts/AdBhashitha.woff') format('woff');
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'LeJour Script';
|
|
||||||
src: url('./fonts/LeJour-Script.woff') format('woff');
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
/* Sage & Cream. 500 is the signature sage; 600+ are the darker tones that white text can actually
|
|
||||||
sit on (500 on white is only 3.6:1 — too low). */
|
|
||||||
--color-bakery-50: #faf7f0;
|
|
||||||
--color-bakery-100: #f0efe3;
|
|
||||||
--color-bakery-200: #dde0cc;
|
|
||||||
--color-bakery-300: #c3cbae;
|
|
||||||
--color-bakery-400: #a2ae8b;
|
|
||||||
--color-bakery-500: #7c8b6b;
|
|
||||||
--color-bakery-600: #5f6f52;
|
|
||||||
--color-bakery-700: #4a5740;
|
|
||||||
--color-bakery-800: #37412f;
|
|
||||||
--color-bakery-900: #232b1e;
|
|
||||||
|
|
||||||
--font-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif;
|
|
||||||
--font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif;
|
|
||||||
--font-lejour: 'LeJour Script', ui-serif, Georgia, cursive;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
/* Nothing on this site is meant to scroll sideways. */
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--color-bakery-50);
|
|
||||||
color: var(--color-bakery-900);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
overflow-x: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeIn 0.5s ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
html { scroll-behavior: auto; }
|
|
||||||
body { animation: none; opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* In @layer components, matching the old site. That ordering matters: a utility like `px-4` — which
|
|
||||||
the markup applies alongside `container` on nearly every section — has to win over this, or every
|
|
||||||
gutter on the site silently widens. */
|
|
||||||
@layer components {
|
|
||||||
.container {
|
|
||||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background-color: var(--color-bakery-300);
|
|
||||||
color: var(--color-bakery-900);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* The catalogue, its ordering, its category filter and its image URLs are all decided by the
|
|
||||||
* backend — this file just fetches them. Same origin, so no base URL and no CORS.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Product {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
/** Absolute, ready to put in a src. Built server-side from the bucket config. */
|
|
||||||
images: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the admin screens get back: the public shape plus where it sits in the order. */
|
|
||||||
export interface AdminProduct extends Product {
|
|
||||||
position: number;
|
|
||||||
/** The same photos as `images`, in the same order — these are what arrangePhotos names them by. */
|
|
||||||
keys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminCategory {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
position: number;
|
|
||||||
/** How many products are filed under it — deleting one that's in use is refused. */
|
|
||||||
used: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
|
||||||
const res = await fetch(path, { headers: { Accept: 'application/json' } });
|
|
||||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchProducts = (category?: string) =>
|
|
||||||
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
|
|
||||||
|
|
||||||
export const fetchCategories = () => get<string[]>('/api/categories');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spring hands the SPA a CSRF token in a cookie and wants it echoed on anything that writes. Read
|
|
||||||
* per request rather than cached: it rotates on sign-in, and a stale token fails exactly like a
|
|
||||||
* missing one. Returns nothing when security is off, which is why the contact form still posts
|
|
||||||
* happily on a deployment with no identity provider.
|
|
||||||
*/
|
|
||||||
export function csrfHeader(): Record<string, string> {
|
|
||||||
const token = document.cookie
|
|
||||||
.split('; ')
|
|
||||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
|
||||||
?.slice('XSRF-TOKEN='.length);
|
|
||||||
return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every admin write funnels through here so one place understands the server's failure shapes: a
|
|
||||||
* 401/403 means the session lapsed (the OIDC chain answers /api with a status rather than bouncing
|
|
||||||
* you to a login page), and anything else carries a ProblemDetail whose `detail` is the sentence
|
|
||||||
* the server wants the editor to read.
|
|
||||||
*/
|
|
||||||
async function send<T>(path: string, method: string, body?: unknown, form?: FormData): Promise<T> {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(form ? {} : { 'Content-Type': 'application/json' }),
|
|
||||||
...csrfHeader(),
|
|
||||||
},
|
|
||||||
body: form ?? (body === undefined ? undefined : JSON.stringify(body)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
throw new Error('Your sign-in has expired — refresh the page to sign in again.');
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const problem = await res.json().catch(() => null);
|
|
||||||
throw new Error(problem?.detail || problem?.error || 'That did not save. Please try again.');
|
|
||||||
}
|
|
||||||
return (res.status === 204 ? undefined : await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- products ---------------------------------------------------------------
|
|
||||||
|
|
||||||
export const adminProducts = () => get<AdminProduct[]>('/api/admin/products');
|
|
||||||
|
|
||||||
export function createProduct(name: string, category: string, photos: File[]) {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('name', name);
|
|
||||||
form.append('category', category);
|
|
||||||
photos.forEach((p) => form.append('photos', p));
|
|
||||||
return send<AdminProduct>('/api/admin/products', 'POST', undefined, form);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const describeProduct = (id: number, name: string, category: string) =>
|
|
||||||
send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', { name, category });
|
|
||||||
|
|
||||||
export const deleteProduct = (id: number) =>
|
|
||||||
send<{ ok: boolean }>(`/api/admin/products/${id}`, 'DELETE');
|
|
||||||
|
|
||||||
export function addPhotos(id: number, photos: File[]) {
|
|
||||||
const form = new FormData();
|
|
||||||
photos.forEach((p) => form.append('photos', p));
|
|
||||||
return send<AdminProduct>(`/api/admin/products/${id}/photos`, 'POST', undefined, form);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The full arrangement the editor is looking at — removing a photo is just an omission. */
|
|
||||||
export const arrangePhotos = (id: number, keys: string[]) =>
|
|
||||||
send<AdminProduct>(`/api/admin/products/${id}/photos`, 'PUT', keys);
|
|
||||||
|
|
||||||
export const reorderProducts = (ids: number[]) =>
|
|
||||||
send<AdminProduct[]>('/api/admin/products/order', 'PUT', { ids });
|
|
||||||
|
|
||||||
// --- categories -------------------------------------------------------------
|
|
||||||
|
|
||||||
export const adminCategories = () => get<AdminCategory[]>('/api/admin/categories');
|
|
||||||
|
|
||||||
export const createCategory = (name: string) =>
|
|
||||||
send<AdminCategory>('/api/admin/categories', 'POST', { name });
|
|
||||||
|
|
||||||
export const renameCategory = (id: number, name: string) =>
|
|
||||||
send<AdminCategory>(`/api/admin/categories/${id}`, 'PUT', { name });
|
|
||||||
|
|
||||||
export const reorderCategories = (ids: number[]) =>
|
|
||||||
send<AdminCategory[]>('/api/admin/categories/order', 'PUT', { ids });
|
|
||||||
|
|
||||||
export const deleteCategory = (id: number) =>
|
|
||||||
send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE');
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
import App from './App';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
|
||||||
</BrowserRouter>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,716 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
addPhotos,
|
|
||||||
adminCategories,
|
|
||||||
adminProducts,
|
|
||||||
arrangePhotos,
|
|
||||||
createCategory,
|
|
||||||
createProduct,
|
|
||||||
deleteCategory,
|
|
||||||
deleteProduct,
|
|
||||||
describeProduct,
|
|
||||||
renameCategory,
|
|
||||||
reorderCategories,
|
|
||||||
reorderProducts,
|
|
||||||
type AdminCategory,
|
|
||||||
type AdminProduct,
|
|
||||||
} from '@/lib/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The catalogue, editable by the person who bakes it.
|
|
||||||
*
|
|
||||||
* The whole screen is built around one rule: nothing waits on the network to look like it happened.
|
|
||||||
* Reordering and deleting apply to the list on screen first and reconcile afterwards, because an
|
|
||||||
* editor tidying twenty items shouldn't be typing into a page that freezes between every click.
|
|
||||||
* Uploads are the exception — they genuinely take a moment (resize, convert, send), so they say so.
|
|
||||||
*
|
|
||||||
* Getting here at all means signing in: /admin is an authenticated path, so an unknown visitor is
|
|
||||||
* sent to the identity provider before this ever loads.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// --- little pieces ----------------------------------------------------------
|
|
||||||
|
|
||||||
const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className={`w-4 h-4 ${className}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d={d} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
|
|
||||||
const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
|
|
||||||
const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
|
|
||||||
const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
|
|
||||||
const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
|
|
||||||
const PLUS = 'M12 5v14M5 12h14';
|
|
||||||
const CHECK = 'M20 6L9 17l-5-5';
|
|
||||||
const X = 'M18 6L6 18M6 6l12 12';
|
|
||||||
|
|
||||||
const button =
|
|
||||||
'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' +
|
|
||||||
'transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
|
|
||||||
const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
|
|
||||||
const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
|
|
||||||
const danger = `${button} text-red-700 hover:bg-red-50`;
|
|
||||||
const iconButton =
|
|
||||||
'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' +
|
|
||||||
'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
|
|
||||||
const field =
|
|
||||||
'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' +
|
|
||||||
'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500';
|
|
||||||
|
|
||||||
/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */
|
|
||||||
function shift<T>(items: T[], index: number, delta: number): T[] {
|
|
||||||
const target = index + delta;
|
|
||||||
if (target < 0 || target >= items.length) return items;
|
|
||||||
const next = [...items];
|
|
||||||
[next[index], next[target]] = [next[target], next[index]];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- photos -----------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The photos on one item. Order matters — the first is the one the products page leads with — so
|
|
||||||
* arranging is left/right rather than a drag target, which is far easier to hit on a phone.
|
|
||||||
*/
|
|
||||||
const Photos = ({
|
|
||||||
product,
|
|
||||||
onArrange,
|
|
||||||
busy,
|
|
||||||
}: {
|
|
||||||
product: AdminProduct;
|
|
||||||
onArrange: (keys: string[]) => void;
|
|
||||||
busy: boolean;
|
|
||||||
}) => (
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{product.images.map((url, i) => (
|
|
||||||
<figure key={product.keys[i] ?? url} className="w-28">
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
className="w-28 h-28 rounded-md object-cover border border-bakery-200 bg-bakery-100"
|
|
||||||
/>
|
|
||||||
<figcaption className="mt-1 flex items-center justify-between gap-1">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={busy || i === 0}
|
|
||||||
onClick={() => onArrange(shift(product.keys, i, -1))}
|
|
||||||
aria-label="Move photo earlier"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_LEFT} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={busy || i === product.images.length - 1}
|
|
||||||
onClick={() => onArrange(shift(product.keys, i, 1))}
|
|
||||||
aria-label="Move photo later"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_RIGHT} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
// The server refuses an empty arrangement; saying so up front beats an error message.
|
|
||||||
disabled={busy || product.images.length === 1}
|
|
||||||
onClick={() => onArrange(product.keys.filter((_, at) => at !== i))}
|
|
||||||
aria-label="Remove photo"
|
|
||||||
title={product.images.length === 1 ? 'An item needs at least one photo' : 'Remove photo'}
|
|
||||||
>
|
|
||||||
<Icon d={TRASH} />
|
|
||||||
</button>
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** A file picker styled as a button, resetting itself so the same file can be chosen twice. */
|
|
||||||
const PhotoPicker = ({
|
|
||||||
label,
|
|
||||||
onPick,
|
|
||||||
disabled,
|
|
||||||
className = secondary,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
onPick: (files: File[]) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const input = useRef<HTMLInputElement>(null);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={input}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const files = Array.from(e.target.files ?? []);
|
|
||||||
e.target.value = '';
|
|
||||||
if (files.length) onPick(files);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="button" className={className} disabled={disabled} onClick={() => input.current?.click()}>
|
|
||||||
<Icon d={PLUS} />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- one item ---------------------------------------------------------------
|
|
||||||
|
|
||||||
const ProductCard = ({
|
|
||||||
product,
|
|
||||||
categories,
|
|
||||||
first,
|
|
||||||
last,
|
|
||||||
onChange,
|
|
||||||
onMove,
|
|
||||||
onDelete,
|
|
||||||
onError,
|
|
||||||
}: {
|
|
||||||
product: AdminProduct;
|
|
||||||
categories: string[];
|
|
||||||
first: boolean;
|
|
||||||
last: boolean;
|
|
||||||
onChange: (updated: AdminProduct) => void;
|
|
||||||
onMove: (delta: number) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
}) => {
|
|
||||||
const [name, setName] = useState(product.name);
|
|
||||||
const [category, setCategory] = useState(product.category);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
// A reorder or an upload re-fetches this product; the fields should follow unless they're being
|
|
||||||
// edited, which is what the dirty check below decides.
|
|
||||||
const dirty = name !== product.name || category !== product.category;
|
|
||||||
const [synced, setSynced] = useState(product);
|
|
||||||
if (synced !== product) {
|
|
||||||
setSynced(product);
|
|
||||||
if (!dirty) {
|
|
||||||
setName(product.name);
|
|
||||||
setCategory(product.category);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = async (work: () => Promise<AdminProduct>) => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
onChange(await work());
|
|
||||||
} catch (e) {
|
|
||||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
|
||||||
<div className="flex sm:flex-col gap-1 sm:pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={first}
|
|
||||||
onClick={() => onMove(-1)}
|
|
||||||
aria-label={`Move ${product.name} up`}
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_UP} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={last}
|
|
||||||
onClick={() => onMove(1)}
|
|
||||||
aria-label={`Move ${product.name} down`}
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_DOWN} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 space-y-3">
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[1fr_12rem]">
|
|
||||||
<label className="block">
|
|
||||||
<span className="sr-only">Name</span>
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="sr-only">Category</span>
|
|
||||||
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
|
|
||||||
{/* A product can sit in a category nobody defined; don't silently retype it. */}
|
|
||||||
{!categories.includes(category) && <option value={category}>{category}</option>}
|
|
||||||
{categories.map((c) => (
|
|
||||||
<option key={c} value={c}>
|
|
||||||
{c}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Photos
|
|
||||||
product={product}
|
|
||||||
busy={busy}
|
|
||||||
onArrange={(keys) => run(() => arrangePhotos(product.id, keys))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<PhotoPicker
|
|
||||||
label="Add photos"
|
|
||||||
disabled={busy}
|
|
||||||
onPick={(files) => run(() => addPhotos(product.id, files))}
|
|
||||||
/>
|
|
||||||
{dirty && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={primary}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => run(() => describeProduct(product.id, name.trim(), category))}
|
|
||||||
>
|
|
||||||
<Icon d={CHECK} />
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={secondary}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
setName(product.name);
|
|
||||||
setCategory(product.category);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon d={X} />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button type="button" className={`${danger} ml-auto`} disabled={busy} onClick={onDelete}>
|
|
||||||
<Icon d={TRASH} />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{busy && <p className="text-sm text-bakery-600">Working…</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- adding an item ---------------------------------------------------------
|
|
||||||
|
|
||||||
const NewItem = ({
|
|
||||||
categories,
|
|
||||||
onAdded,
|
|
||||||
onError,
|
|
||||||
}: {
|
|
||||||
categories: string[];
|
|
||||||
onAdded: (product: AdminProduct) => void;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
}) => {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [category, setCategory] = useState(categories[0] ?? '');
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
// The category list arrives after the first render, so the default has to catch up once.
|
|
||||||
useEffect(() => {
|
|
||||||
setCategory((c) => (c || categories[0] || ''));
|
|
||||||
}, [categories]);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
onAdded(await createProduct(name.trim(), category, files));
|
|
||||||
setName('');
|
|
||||||
setFiles([]);
|
|
||||||
} catch (e) {
|
|
||||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
|
||||||
<h2 className="font-adbhashitha text-xl text-bakery-800">Add something new</h2>
|
|
||||||
<p className="mt-1 text-sm text-bakery-600">New items go to the top of the products page.</p>
|
|
||||||
|
|
||||||
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_12rem]">
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="What is it? e.g. Chocolate drip cake"
|
|
||||||
/>
|
|
||||||
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<option key={c} value={c}>
|
|
||||||
{c}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{files.map((file, i) => (
|
|
||||||
<div key={`${file.name}-${i}`} className="relative">
|
|
||||||
<img
|
|
||||||
src={URL.createObjectURL(file)}
|
|
||||||
alt=""
|
|
||||||
className="w-20 h-20 rounded-md object-cover border border-bakery-200"
|
|
||||||
// Revoking once it's painted keeps the preview from holding the file in memory.
|
|
||||||
onLoad={(e) => URL.revokeObjectURL(e.currentTarget.src)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-white border border-bakery-300 text-bakery-700 flex items-center justify-center"
|
|
||||||
onClick={() => setFiles(files.filter((_, at) => at !== i))}
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
|
||||||
<Icon d={X} className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
<PhotoPicker
|
|
||||||
label={files.length ? 'Add more photos' : 'Choose photos'}
|
|
||||||
disabled={busy}
|
|
||||||
onPick={(picked) => setFiles((current) => [...current, ...picked])}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={primary}
|
|
||||||
disabled={busy || !name.trim() || !category || files.length === 0}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
<Icon d={PLUS} />
|
|
||||||
{busy ? 'Uploading…' : 'Add to the page'}
|
|
||||||
</button>
|
|
||||||
{busy && <span className="text-sm text-bakery-600">Photos can take a few seconds each.</span>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- categories -------------------------------------------------------------
|
|
||||||
|
|
||||||
const Categories = ({
|
|
||||||
categories,
|
|
||||||
setCategories,
|
|
||||||
onError,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
categories: AdminCategory[];
|
|
||||||
setCategories: (next: AdminCategory[]) => void;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
onChanged: () => void;
|
|
||||||
}) => {
|
|
||||||
const [fresh, setFresh] = useState('');
|
|
||||||
const [editing, setEditing] = useState<number | null>(null);
|
|
||||||
const [draft, setDraft] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const guard = async (work: () => Promise<unknown>, optimistic?: AdminCategory[]) => {
|
|
||||||
const before = categories;
|
|
||||||
if (optimistic) setCategories(optimistic);
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await work();
|
|
||||||
onChanged();
|
|
||||||
} catch (e) {
|
|
||||||
if (optimistic) setCategories(before);
|
|
||||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
|
||||||
<h2 className="font-adbhashitha text-xl text-bakery-800">Categories</h2>
|
|
||||||
<p className="mt-1 text-sm text-bakery-600">
|
|
||||||
These are the filter buttons on the products page, in this order. Renaming one moves
|
|
||||||
everything filed under it too.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="mt-3 divide-y divide-bakery-100">
|
|
||||||
{categories.map((category, i) => (
|
|
||||||
<li key={category.id} className="flex items-center gap-2 py-2">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={busy || i === 0}
|
|
||||||
onClick={() =>
|
|
||||||
guard(
|
|
||||||
() => reorderCategories(shift(categories, i, -1).map((c) => c.id)),
|
|
||||||
shift(categories, i, -1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
aria-label={`Move ${category.name} up`}
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_UP} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={busy || i === categories.length - 1}
|
|
||||||
onClick={() =>
|
|
||||||
guard(
|
|
||||||
() => reorderCategories(shift(categories, i, 1).map((c) => c.id)),
|
|
||||||
shift(categories, i, 1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
aria-label={`Move ${category.name} down`}
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_DOWN} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editing === category.id ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={draft}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Escape' && setEditing(null)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={primary}
|
|
||||||
disabled={busy || !draft.trim()}
|
|
||||||
onClick={() =>
|
|
||||||
guard(async () => {
|
|
||||||
await renameCategory(category.id, draft.trim());
|
|
||||||
setEditing(null);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon d={CHECK} />
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button type="button" className={secondary} onClick={() => setEditing(null)}>
|
|
||||||
<Icon d={X} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="flex-1 text-bakery-900">{category.name}</span>
|
|
||||||
<span className="text-sm text-bakery-500">
|
|
||||||
{category.used} item{category.used === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={secondary}
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(category.id);
|
|
||||||
setDraft(category.name);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={danger}
|
|
||||||
disabled={busy || category.used > 0}
|
|
||||||
title={category.used > 0 ? 'Move its items somewhere else first' : 'Delete'}
|
|
||||||
onClick={() =>
|
|
||||||
guard(
|
|
||||||
() => deleteCategory(category.id),
|
|
||||||
categories.filter((c) => c.id !== category.id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon d={TRASH} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={fresh}
|
|
||||||
onChange={(e) => setFresh(e.target.value)}
|
|
||||||
placeholder="New category"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && guard(async () => {
|
|
||||||
await createCategory(fresh.trim());
|
|
||||||
setFresh('');
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={primary}
|
|
||||||
disabled={busy || !fresh.trim()}
|
|
||||||
onClick={() =>
|
|
||||||
guard(async () => {
|
|
||||||
await createCategory(fresh.trim());
|
|
||||||
setFresh('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon d={PLUS} />
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- the page ---------------------------------------------------------------
|
|
||||||
|
|
||||||
const AdminPage = () => {
|
|
||||||
const [products, setProducts] = useState<AdminProduct[] | null>(null);
|
|
||||||
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [items, cats] = await Promise.all([adminProducts(), adminCategories()]);
|
|
||||||
setProducts(items);
|
|
||||||
setCategories(cats);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Could not load the catalogue.');
|
|
||||||
setProducts([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const names = categories.map((c) => c.name);
|
|
||||||
|
|
||||||
/** Reorder and delete both apply on screen first — the point of this page is that it keeps up. */
|
|
||||||
const settle = async (optimistic: AdminProduct[], work: () => Promise<unknown>) => {
|
|
||||||
const before = products ?? [];
|
|
||||||
setProducts(optimistic);
|
|
||||||
try {
|
|
||||||
await work();
|
|
||||||
} catch (e) {
|
|
||||||
setProducts(before);
|
|
||||||
setError(e instanceof Error ? e.message : 'That did not save.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
|
|
||||||
<p className="text-bakery-600">Everything on the products page lives here.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<a href="/products" className={secondary}>
|
|
||||||
View the page
|
|
||||||
</a>
|
|
||||||
{/* A real form post: the platform's logout expects one, and it also ends the Authentik session. */}
|
|
||||||
<form method="post" action="/logout">
|
|
||||||
<button type="submit" className={secondary}>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-6 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
|
|
||||||
>
|
|
||||||
<span className="flex-1">{error}</span>
|
|
||||||
<button type="button" onClick={() => setError('')} aria-label="Dismiss">
|
|
||||||
<Icon d={X} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{products === null ? (
|
|
||||||
<p className="mt-10 text-bakery-600">Loading…</p>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 space-y-6">
|
|
||||||
<Categories
|
|
||||||
categories={categories}
|
|
||||||
setCategories={setCategories}
|
|
||||||
onError={setError}
|
|
||||||
// A rename rewrites the products filed under it, so the list has to come back fresh.
|
|
||||||
onChanged={() => void load()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NewItem
|
|
||||||
categories={names}
|
|
||||||
onError={setError}
|
|
||||||
onAdded={(product) => setProducts([product, ...products])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="font-adbhashitha text-xl text-bakery-800">
|
|
||||||
On the page ({products.length})
|
|
||||||
</h2>
|
|
||||||
<ul className="mt-3 space-y-3">
|
|
||||||
{products.map((product, i) => (
|
|
||||||
<ProductCard
|
|
||||||
key={product.id}
|
|
||||||
product={product}
|
|
||||||
categories={names}
|
|
||||||
first={i === 0}
|
|
||||||
last={i === products.length - 1}
|
|
||||||
onError={setError}
|
|
||||||
onChange={(updated) =>
|
|
||||||
setProducts(products.map((p) => (p.id === updated.id ? updated : p)))
|
|
||||||
}
|
|
||||||
onMove={(delta) => {
|
|
||||||
const moved = shift(products, i, delta);
|
|
||||||
void settle(moved, () => reorderProducts(moved.map((p) => p.id)));
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
if (!confirm(`Remove ${product.name} from the products page?`)) return;
|
|
||||||
void settle(
|
|
||||||
products.filter((p) => p.id !== product.id),
|
|
||||||
() => deleteProduct(product.id),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{products.length === 0 && (
|
|
||||||
<p className="mt-3 text-bakery-600">Nothing here yet — add something above.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminPage;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"types": ["vite/client", "vite-plugin-svgr/client", "node"],
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"paths": { "@/*": ["./src/*"] }
|
|
||||||
},
|
|
||||||
"include": ["src", "vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
import svgr from 'vite-plugin-svgr';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
// svgr keeps the `import Logo from './x.svg?react'` style the old @svgr/webpack setup used, so the
|
|
||||||
// logo marks stay inline SVG and inherit currentColor.
|
|
||||||
plugins: [react(), tailwindcss(), svgr()],
|
|
||||||
resolve: {
|
|
||||||
alias: { '@': path.resolve(__dirname, './src') },
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 2024,
|
|
||||||
// Backend on :8080 during development; the built SPA is served by Spring, same origin.
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:8080',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
// The photos live in MinIO, so what's left is small — a warning here would mean a real regression.
|
|
||||||
chunkSizeWarningLimit: 600,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -58,6 +58,13 @@
|
|||||||
<groupId>net.thebennett.platform</groupId>
|
<groupId>net.thebennett.platform</groupId>
|
||||||
<artifactId>platform-starter-data</artifactId>
|
<artifactId>platform-starter-data</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>net.thebennett.platform</groupId>
|
<groupId>net.thebennett.platform</groupId>
|
||||||
<artifactId>platform-starter-contact</artifactId>
|
<artifactId>platform-starter-contact</artifactId>
|
||||||
@@ -117,14 +124,55 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
</plugin>
|
</plugin>
|
||||||
<!-- SPA build inherited from platform-parent (node install + npm build + copy dist -> jar). -->
|
<!--
|
||||||
|
There is no JavaScript application here any more, so node is present for one reason:
|
||||||
|
Tailwind needs a compiler, and the alternative to it is a hand-written stylesheet.
|
||||||
|
src/main/styles is the whole asset pipeline (see its site.css).
|
||||||
|
-->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>com.github.eirslett</groupId>
|
<groupId>com.github.eirslett</groupId>
|
||||||
<artifactId>frontend-maven-plugin</artifactId>
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<!-- Overrides platform-parent's `frontend`, which was where the SPA lived. The
|
||||||
|
stylesheet sources live with the rest of the app's source; node_modules has to
|
||||||
|
sit beside them, because that is where Tailwind resolves its own import from. -->
|
||||||
|
<workingDirectory>src/main/styles</workingDirectory>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<!--
|
||||||
|
Tailwind, over every template, straight into the build output.
|
||||||
|
|
||||||
|
process-classes, not prepare-package: `mvn spring-boot:run` stops at
|
||||||
|
process-classes, and bound any later a local run would serve an unstyled site. The
|
||||||
|
output goes to target/classes/static/css so the generated file can never be
|
||||||
|
mistaken for a source file.
|
||||||
|
-->
|
||||||
|
<execution>
|
||||||
|
<id>npm-build-css</id>
|
||||||
|
<phase>process-classes</phase>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration><arguments>run build:css</arguments></configuration>
|
||||||
|
</execution>
|
||||||
|
<!-- Inherited from platform-parent to build an SPA at prepare-package. There isn't
|
||||||
|
one; the stylesheet is already built above. -->
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<phase>none</phase>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-resources-plugin</artifactId>
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<!-- Same: this copied frontend/dist into the jar's static/. No dist, nothing to
|
||||||
|
copy — the pages are templates and the stylesheet is written straight to
|
||||||
|
target/classes/static/css. -->
|
||||||
|
<execution>
|
||||||
|
<id>copy-frontend</id>
|
||||||
|
<phase>none</phase>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
package com.itsthevine.web;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import com.itsthevine.web.domain.Category;
|
|
||||||
import com.itsthevine.web.domain.CategoryRepository;
|
|
||||||
import com.itsthevine.web.domain.Product;
|
|
||||||
import com.itsthevine.web.domain.ProductRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The filter buttons, editable. Gated on OIDC for the same reason as the product admin: with no
|
|
||||||
* identity provider configured these endpoints shouldn't exist at all.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/admin/categories")
|
|
||||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
|
||||||
public class AdminCategoryController {
|
|
||||||
|
|
||||||
private final CategoryRepository categories;
|
|
||||||
private final ProductRepository products;
|
|
||||||
|
|
||||||
public AdminCategoryController(CategoryRepository categories, ProductRepository products) {
|
|
||||||
this.categories = categories;
|
|
||||||
this.products = products;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@code used} tells the editor whether deleting it would strand anything. */
|
|
||||||
public record AdminView(Long id, String name, int position, long used) {}
|
|
||||||
|
|
||||||
public record Name(String name) {}
|
|
||||||
|
|
||||||
public record Order(List<Long> ids) {}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<AdminView> list() {
|
|
||||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
|
||||||
return categories.findAllByOrderByPositionAsc().stream()
|
|
||||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(all, c.getName())))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@Transactional
|
|
||||||
public AdminView create(@RequestBody Name body) {
|
|
||||||
String name = required(body.name());
|
|
||||||
categories.findByNameIgnoreCase(name).ifPresent(existing -> {
|
|
||||||
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
|
|
||||||
});
|
|
||||||
int last = categories.findAllByOrderByPositionAsc().stream()
|
|
||||||
.mapToInt(Category::getPosition).max().orElse(0);
|
|
||||||
Category saved = categories.save(new Category(name, last + 1));
|
|
||||||
return new AdminView(saved.getId(), saved.getName(), saved.getPosition(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renaming carries the products with it. They store the category by name, so without this the
|
|
||||||
* rename would orphan everything filed under the old one — it would drop off the filter and
|
|
||||||
* reappear at the end as an unlisted category.
|
|
||||||
*/
|
|
||||||
@PutMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public AdminView rename(@PathVariable Long id, @RequestBody Name body) {
|
|
||||||
Category category = find(id);
|
|
||||||
String name = required(body.name());
|
|
||||||
categories.findByNameIgnoreCase(name)
|
|
||||||
.filter(other -> !other.getId().equals(id))
|
|
||||||
.ifPresent(other -> {
|
|
||||||
throw new IllegalStateException("There's already a " + other.getName() + " category.");
|
|
||||||
});
|
|
||||||
|
|
||||||
String previous = category.getName();
|
|
||||||
category.rename(name);
|
|
||||||
categories.save(category);
|
|
||||||
|
|
||||||
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
|
|
||||||
filed.forEach(p -> p.describe(p.getName(), name));
|
|
||||||
products.saveAll(filed);
|
|
||||||
|
|
||||||
return new AdminView(category.getId(), category.getName(), category.getPosition(), filed.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/order")
|
|
||||||
@Transactional
|
|
||||||
public List<AdminView> reorder(@RequestBody Order order) {
|
|
||||||
List<Category> all = categories.findAllByOrderByPositionAsc();
|
|
||||||
List<Category> arranged = new ArrayList<>();
|
|
||||||
for (Long id : order.ids()) {
|
|
||||||
all.stream().filter(c -> c.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
|
||||||
}
|
|
||||||
all.stream().filter(c -> !arranged.contains(c)).forEach(arranged::add);
|
|
||||||
|
|
||||||
int position = 1;
|
|
||||||
for (Category category : arranged) {
|
|
||||||
category.moveTo(position++);
|
|
||||||
}
|
|
||||||
categories.saveAll(arranged);
|
|
||||||
|
|
||||||
List<Product> everything = products.findAllByOrderByPositionAsc();
|
|
||||||
return arranged.stream()
|
|
||||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(everything, c.getName())))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
|
||||||
Category category = find(id);
|
|
||||||
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
|
|
||||||
if (used > 0) {
|
|
||||||
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
|
|
||||||
// the items behind it.
|
|
||||||
throw new IllegalStateException(
|
|
||||||
used + " item" + (used == 1 ? " is" : "s are") + " still filed under "
|
|
||||||
+ category.getName() + ". Move them first.");
|
|
||||||
}
|
|
||||||
categories.delete(category);
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Category find(Long id) {
|
|
||||||
return categories.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long count(List<Product> all, String category) {
|
|
||||||
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String required(String value) {
|
|
||||||
String trimmed = value == null ? "" : value.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Please give the category a name.");
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catering price tables, editable as forms.
|
||||||
|
*
|
||||||
|
* <p>A table is edited and saved whole, which is the same rule the React screen followed and for the
|
||||||
|
* same reason: a column heading, its price and the entries beneath it only mean anything together, so
|
||||||
|
* they have to be added, moved and removed together. {@code CateringPackage#arrange} refuses an
|
||||||
|
* arrangement whose lines and columns disagree.
|
||||||
|
*
|
||||||
|
* <p>The interesting part is doing that without JavaScript. One form holds the whole table, and its
|
||||||
|
* buttons all submit it — {@code name="do"} says which one was pressed. "Add a column" therefore arrives
|
||||||
|
* with every cell the editor has typed so far, adds the column to what arrived, and re-renders; nothing
|
||||||
|
* typed is lost, and nothing is written until Save. The alternative — a link that adds a column
|
||||||
|
* server-side — would have to either discard the unsaved edits or write a half-built table to the live
|
||||||
|
* page.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin/catering")
|
||||||
|
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||||
|
public class AdminCateringController {
|
||||||
|
|
||||||
|
private final CateringMenu catering;
|
||||||
|
|
||||||
|
public AdminCateringController(CateringMenu catering) {
|
||||||
|
this.catering = catering;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One table, as the form posts it back.
|
||||||
|
*
|
||||||
|
* <p>A form-backing object rather than a pile of {@code @RequestParam} lists, because the cells are a
|
||||||
|
* grid: Spring binds {@code lines[2].values[1]} into exactly the right place, whereas flat repeated
|
||||||
|
* parameters would rely on the browser's submission order to keep the grid square.
|
||||||
|
*/
|
||||||
|
public static class TableForm {
|
||||||
|
|
||||||
|
private String name = "";
|
||||||
|
private String blurb = "";
|
||||||
|
private List<ColumnForm> columns = new ArrayList<>();
|
||||||
|
private List<LineForm> lines = new ArrayList<>();
|
||||||
|
private List<String> notes = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getBlurb() { return blurb; }
|
||||||
|
public void setBlurb(String blurb) { this.blurb = blurb; }
|
||||||
|
|
||||||
|
public List<ColumnForm> getColumns() { return columns; }
|
||||||
|
public void setColumns(List<ColumnForm> columns) { this.columns = columns; }
|
||||||
|
|
||||||
|
public List<LineForm> getLines() { return lines; }
|
||||||
|
public void setLines(List<LineForm> lines) { this.lines = lines; }
|
||||||
|
|
||||||
|
public List<String> getNotes() { return notes; }
|
||||||
|
public void setNotes(List<String> notes) { this.notes = notes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ColumnForm {
|
||||||
|
|
||||||
|
/** Null for a column the editor has just added and not yet saved. */
|
||||||
|
private Long id;
|
||||||
|
private String label = "";
|
||||||
|
private String price = "";
|
||||||
|
private String serves = "";
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getLabel() { return label; }
|
||||||
|
public void setLabel(String label) { this.label = label; }
|
||||||
|
|
||||||
|
public String getPrice() { return price; }
|
||||||
|
public void setPrice(String price) { this.price = price; }
|
||||||
|
|
||||||
|
public String getServes() { return serves; }
|
||||||
|
public void setServes(String serves) { this.serves = serves; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LineForm {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String label = "";
|
||||||
|
private List<String> values = new ArrayList<>();
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getLabel() { return label; }
|
||||||
|
public void setLabel(String label) { this.label = label; }
|
||||||
|
|
||||||
|
public List<String> getValues() { return values; }
|
||||||
|
public void setValues(List<String> values) { this.values = values; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tables, listed: rename or fill one in on its own page, and set the page's own notes here. */
|
||||||
|
@GetMapping
|
||||||
|
public String tables(Model model) {
|
||||||
|
model.addAttribute("menu", catering.everything());
|
||||||
|
return "admin/catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One table's editor.
|
||||||
|
*
|
||||||
|
* <p>A page per table rather than every table on one screen: a table is saved whole, so the thing
|
||||||
|
* being edited and the thing being saved should be the same thing you can see.
|
||||||
|
*/
|
||||||
|
@GetMapping("/tables/{id}")
|
||||||
|
public String edit(@PathVariable Long id, Model model) {
|
||||||
|
CateringMenu.PackageView table = catering.everything().packages().stream()
|
||||||
|
.filter(p -> p.id().equals(id))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (table == null) {
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
model.addAttribute("table", formOf(table));
|
||||||
|
model.addAttribute("tableId", id);
|
||||||
|
return "admin/table";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tables")
|
||||||
|
public String add(@RequestParam String name, RedirectAttributes flash) {
|
||||||
|
try {
|
||||||
|
catering.add(name);
|
||||||
|
flash.addFlashAttribute("done", "Added the " + name.trim() + " table. Give it a column and a line.");
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save, or restructure and come back.
|
||||||
|
*
|
||||||
|
* @param action which button was pressed: {@code save}, {@code add-column}, {@code add-line}, or one
|
||||||
|
* of {@code remove-column}/{@code move-column}/{@code remove-line}/{@code move-line}
|
||||||
|
* with the position after a colon ({@code move-column:2:-1}). A button can only send
|
||||||
|
* its own name and value, so the value carries the argument.
|
||||||
|
*/
|
||||||
|
@PostMapping("/tables/{id}")
|
||||||
|
public String save(@PathVariable Long id,
|
||||||
|
@ModelAttribute("table") TableForm form,
|
||||||
|
@RequestParam(name = "do", defaultValue = "save") String action,
|
||||||
|
Model model,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
if (!action.equals("save")) {
|
||||||
|
restructure(form, action);
|
||||||
|
// Deliberately NOT a redirect: this is a draft, not a saved state. Re-rendering the form the
|
||||||
|
// editor is looking at keeps every cell they have typed; writing it now would put a column
|
||||||
|
// headed "" on the live page, and the table refuses that anyway.
|
||||||
|
model.addAttribute("tableId", id);
|
||||||
|
model.addAttribute("unsaved", true);
|
||||||
|
return "admin/table";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
catering.save(id, new CateringMenu.PackageEdit(
|
||||||
|
form.getName(),
|
||||||
|
form.getBlurb(),
|
||||||
|
form.getColumns().stream()
|
||||||
|
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice(), c.getServes()))
|
||||||
|
.toList(),
|
||||||
|
form.getLines().stream()
|
||||||
|
.map(l -> new CateringMenu.RowEdit(l.getId(), l.getLabel(), l.getValues()))
|
||||||
|
.toList(),
|
||||||
|
form.getNotes()));
|
||||||
|
flash.addFlashAttribute("done", "Saved the " + form.getName().trim() + " table.");
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
// Back to the form with what they typed, and the reason. A redirect here would throw away the
|
||||||
|
// work and leave them guessing which cell the message was about.
|
||||||
|
model.addAttribute("tableId", id);
|
||||||
|
model.addAttribute("unsaved", true);
|
||||||
|
model.addAttribute("problem", e.getMessage());
|
||||||
|
return "admin/table";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tables/{id}/move")
|
||||||
|
public String move(@PathVariable Long id, @RequestParam int by) {
|
||||||
|
List<Long> ids = new ArrayList<>(catering.everything().packages().stream()
|
||||||
|
.map(CateringMenu.PackageView::id).toList());
|
||||||
|
int at = ids.indexOf(id);
|
||||||
|
int to = at + by;
|
||||||
|
if (at >= 0 && to >= 0 && to < ids.size()) {
|
||||||
|
swap(ids, at, to);
|
||||||
|
catering.reorder(ids);
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tables/{id}/delete")
|
||||||
|
public String remove(@PathVariable Long id, RedirectAttributes flash) {
|
||||||
|
try {
|
||||||
|
catering.remove(id);
|
||||||
|
flash.addFlashAttribute("done", "Table deleted.");
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/notes")
|
||||||
|
public String notes(@RequestParam(name = "notes", required = false) List<String> notes,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
try {
|
||||||
|
catering.replaceNotes(notes == null ? List.of() : notes);
|
||||||
|
flash.addFlashAttribute("done", "Saved the notes for the page.");
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a structural button to the draft that arrived.
|
||||||
|
*
|
||||||
|
* <p>Adding a column adds an empty entry to every line, and removing one takes its entries with it,
|
||||||
|
* which is the invariant the aggregate insists on. Doing it here rather than in the browser is the
|
||||||
|
* whole point: there is one implementation of "a table has as many entries per line as it has
|
||||||
|
* columns", and it is in Java.
|
||||||
|
*/
|
||||||
|
private static void restructure(TableForm form, String action) {
|
||||||
|
String[] parts = action.split(":");
|
||||||
|
String what = parts[0];
|
||||||
|
int at = parts.length > 1 ? Integer.parseInt(parts[1]) : -1;
|
||||||
|
int by = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
|
||||||
|
|
||||||
|
switch (what) {
|
||||||
|
case "add-column" -> {
|
||||||
|
form.getColumns().add(new ColumnForm());
|
||||||
|
form.getLines().forEach(line -> line.getValues().add(""));
|
||||||
|
}
|
||||||
|
case "remove-column" -> {
|
||||||
|
if (inRange(at, form.getColumns().size())) {
|
||||||
|
form.getColumns().remove(at);
|
||||||
|
form.getLines().forEach(line -> {
|
||||||
|
if (inRange(at, line.getValues().size())) {
|
||||||
|
line.getValues().remove(at);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "move-column" -> {
|
||||||
|
int to = at + by;
|
||||||
|
if (inRange(at, form.getColumns().size()) && inRange(to, form.getColumns().size())) {
|
||||||
|
swap(form.getColumns(), at, to);
|
||||||
|
form.getLines().forEach(line -> swap(line.getValues(), at, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "add-line" -> {
|
||||||
|
LineForm line = new LineForm();
|
||||||
|
form.getColumns().forEach(column -> line.getValues().add(""));
|
||||||
|
form.getLines().add(line);
|
||||||
|
}
|
||||||
|
case "remove-line" -> {
|
||||||
|
if (inRange(at, form.getLines().size())) {
|
||||||
|
form.getLines().remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "move-line" -> {
|
||||||
|
int to = at + by;
|
||||||
|
if (inRange(at, form.getLines().size()) && inRange(to, form.getLines().size())) {
|
||||||
|
swap(form.getLines(), at, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "add-note" -> form.getNotes().add("");
|
||||||
|
case "remove-note" -> {
|
||||||
|
if (inRange(at, form.getNotes().size())) {
|
||||||
|
form.getNotes().remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An unknown action is a stale page or a hand-edited form: leave the draft exactly as it is
|
||||||
|
// rather than guessing at an edit nobody asked for.
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored table, as a form to edit. */
|
||||||
|
private static TableForm formOf(CateringMenu.PackageView table) {
|
||||||
|
TableForm form = new TableForm();
|
||||||
|
form.setName(table.name());
|
||||||
|
form.setBlurb(table.blurb() == null ? "" : table.blurb());
|
||||||
|
form.setColumns(table.tiers().stream().map(tier -> {
|
||||||
|
ColumnForm column = new ColumnForm();
|
||||||
|
column.setId(tier.id());
|
||||||
|
column.setLabel(tier.label());
|
||||||
|
// The price comes back written out ("$24") and goes out again as whatever is left in the box;
|
||||||
|
// Money reads either.
|
||||||
|
column.setPrice(tier.price() == null ? "" : tier.price());
|
||||||
|
column.setServes(tier.serves() == null ? "" : tier.serves());
|
||||||
|
return column;
|
||||||
|
}).collect(Collectors.toCollection(ArrayList::new)));
|
||||||
|
form.setLines(table.rows().stream().map(row -> {
|
||||||
|
LineForm line = new LineForm();
|
||||||
|
line.setId(row.id());
|
||||||
|
line.setLabel(row.label());
|
||||||
|
line.setValues(new ArrayList<>(row.values()));
|
||||||
|
return line;
|
||||||
|
}).collect(Collectors.toCollection(ArrayList::new)));
|
||||||
|
form.setNotes(new ArrayList<>(table.notes()));
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean inRange(int at, int size) {
|
||||||
|
return at >= 0 && at < size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> void swap(List<T> items, int a, int b) {
|
||||||
|
T held = items.get(a);
|
||||||
|
items.set(a, items.get(b));
|
||||||
|
items.set(b, held);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue, editable by the person who bakes it — as pages and form posts.
|
||||||
|
*
|
||||||
|
* <p>Every write is POST, then a redirect back to the page it came from. That is not ceremony: it means
|
||||||
|
* the browser's back button and reload do what they look like they do, a double-tap can't repeat an
|
||||||
|
* upload, and there is no client-side state to lose. The message the editor reads afterwards travels as
|
||||||
|
* a flash attribute.
|
||||||
|
*
|
||||||
|
* <p>The whole controller is conditional on OIDC being switched on, the same as the JSON admin it
|
||||||
|
* replaced. That is deliberate belt-and-braces: the platform's permit-all filter chain is what runs when
|
||||||
|
* {@code platform.security.mode} is unset, so if these pages existed unconditionally a deployment that
|
||||||
|
* forgot to configure Authentik would be publishing catalogue writes to the open internet. Gated this
|
||||||
|
* way, "no auth configured" means "no admin" — the paths 404 like any other unknown URL.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||||
|
public class AdminController {
|
||||||
|
|
||||||
|
private final Catalogue catalogue;
|
||||||
|
|
||||||
|
public AdminController(Catalogue catalogue) {
|
||||||
|
this.catalogue = catalogue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public String catalogue(Model model) {
|
||||||
|
model.addAttribute("items", catalogue.items());
|
||||||
|
model.addAttribute("filters", catalogue.filters());
|
||||||
|
return "admin/catalogue";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- items ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@PostMapping("/items")
|
||||||
|
public String add(@RequestParam String name,
|
||||||
|
@RequestParam String category,
|
||||||
|
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.addItem(name, category, photos);
|
||||||
|
flash.addFlashAttribute("done", "Added " + name.trim() + " to the top of the page.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything you can do to one item, from one form.
|
||||||
|
*
|
||||||
|
* <p>One form per item rather than one per button: the page carries forty items, and a separate form
|
||||||
|
* for each control meant 467 forms and 464 CSRF tokens — 317 KB of HTML for a screen that is opened
|
||||||
|
* on a phone, in a bakery. {@code name="do"} says which button was pressed and its value carries the
|
||||||
|
* argument, exactly as the catering table editor does.
|
||||||
|
*
|
||||||
|
* <p>Only {@code save} looks at the name and category boxes. The other actions deliberately ignore
|
||||||
|
* them, so pressing "move down" halfway through retyping a name doesn't save the half-typed name.
|
||||||
|
*
|
||||||
|
* @param action {@code save}, {@code move:-1}, {@code move:1}, {@code delete}, or
|
||||||
|
* {@code photo:<key>:<-1|1|0>} — earlier, later, or remove
|
||||||
|
*/
|
||||||
|
@PostMapping("/items/{id}")
|
||||||
|
public String item(@PathVariable Long id,
|
||||||
|
@RequestParam(name = "do", defaultValue = "save") String action,
|
||||||
|
@RequestParam(required = false) String name,
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
String[] parts = action.split(":");
|
||||||
|
return run(flash, () -> {
|
||||||
|
switch (parts[0]) {
|
||||||
|
case "save" -> {
|
||||||
|
catalogue.describeItem(id, name, category);
|
||||||
|
flash.addFlashAttribute("done", "Saved " + name.trim() + ".");
|
||||||
|
}
|
||||||
|
case "move" -> catalogue.moveItem(id, Integer.parseInt(parts[1]));
|
||||||
|
case "delete" -> {
|
||||||
|
catalogue.removeItem(id);
|
||||||
|
flash.addFlashAttribute("done", "Removed from the products page.");
|
||||||
|
}
|
||||||
|
case "photo" -> {
|
||||||
|
// The key is the middle field; it can contain slashes and dots but never a colon.
|
||||||
|
String key = parts[1];
|
||||||
|
int move = Integer.parseInt(parts[2]);
|
||||||
|
if (move == 0) {
|
||||||
|
catalogue.removePhoto(id, key);
|
||||||
|
} else {
|
||||||
|
catalogue.movePhoto(id, key, move);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A stale page, or a hand-edited form. Do nothing rather than guess.
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/items/{id}/photos")
|
||||||
|
public String addPhotos(@PathVariable Long id,
|
||||||
|
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.addPhotos(id, photos);
|
||||||
|
flash.addFlashAttribute("done", "Photos added.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- filters -------------------------------------------------------------
|
||||||
|
|
||||||
|
@PostMapping("/categories")
|
||||||
|
public String addFilter(@RequestParam String name, RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.addFilter(name);
|
||||||
|
flash.addFlashAttribute("done", "Added the " + name.trim() + " category.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything you can do to one filter, from one form — same shape as an item.
|
||||||
|
*
|
||||||
|
* @param action {@code save}, {@code move:-1}, {@code move:1} or {@code delete}
|
||||||
|
*/
|
||||||
|
@PostMapping("/categories/{id}")
|
||||||
|
public String filter(@PathVariable Long id,
|
||||||
|
@RequestParam(name = "do", defaultValue = "save") String action,
|
||||||
|
@RequestParam(required = false) String name,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
String[] parts = action.split(":");
|
||||||
|
return run(flash, () -> {
|
||||||
|
switch (parts[0]) {
|
||||||
|
case "save" -> {
|
||||||
|
catalogue.renameFilter(id, name);
|
||||||
|
flash.addFlashAttribute("done",
|
||||||
|
"Renamed to " + name.trim() + ", and everything filed under it moved too.");
|
||||||
|
}
|
||||||
|
case "move" -> catalogue.moveFilter(id, Integer.parseInt(parts[1]));
|
||||||
|
case "delete" -> {
|
||||||
|
catalogue.removeFilter(id);
|
||||||
|
flash.addFlashAttribute("done", "Category deleted.");
|
||||||
|
}
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one edit and comes back to the page.
|
||||||
|
*
|
||||||
|
* <p>The two exception types are the vocabulary the domain already speaks — {@code
|
||||||
|
* IllegalArgumentException} for "that isn't a usable value", {@code IllegalStateException} for "not
|
||||||
|
* while things are like this" — and both carry a sentence written for the editor to read. The
|
||||||
|
* platform's exception handler turns them into JSON for the API; here they belong on the page.
|
||||||
|
*/
|
||||||
|
private String run(RedirectAttributes flash, Runnable edit) {
|
||||||
|
try {
|
||||||
|
edit.run();
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
package com.itsthevine.web;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import com.itsthevine.web.domain.Product;
|
|
||||||
import com.itsthevine.web.domain.ProductRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Editing the catalogue from the site, so a new cake is a photo and a name rather than a migration.
|
|
||||||
*
|
|
||||||
* The whole controller is conditional on OIDC being switched on. That is deliberate belt-and-braces:
|
|
||||||
* the platform's permit-all filter chain is what runs when {@code platform.security.mode} is unset,
|
|
||||||
* so if these endpoints existed unconditionally a deployment that forgot to configure Authentik
|
|
||||||
* would be publishing catalogue writes to the open internet. Gated this way, "no auth configured"
|
|
||||||
* means "no admin endpoints" — they 404 like any other unknown path, which is also what the platform
|
|
||||||
* web contract expects of {@code /api/**}.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/admin/products")
|
|
||||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
|
||||||
public class AdminProductController {
|
|
||||||
|
|
||||||
private final ProductRepository products;
|
|
||||||
private final ProductPhotoService photos;
|
|
||||||
private final ProductCatalog catalog;
|
|
||||||
|
|
||||||
public AdminProductController(ProductRepository products, ProductPhotoService photos, ProductCatalog catalog) {
|
|
||||||
this.products = products;
|
|
||||||
this.photos = photos;
|
|
||||||
this.catalog = catalog;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the editor sees: the catalogue in display order.
|
|
||||||
*
|
|
||||||
* {@code images} and {@code keys} are the same photos in the same order — the URLs to show and the
|
|
||||||
* identifiers to arrange by. The public view only needs the former, but an editor rearranging
|
|
||||||
* photos has to name them back to us, and the URL is a rendering of the key rather than the key
|
|
||||||
* itself.
|
|
||||||
*/
|
|
||||||
public record AdminView(Long id, String name, String category, int position,
|
|
||||||
List<String> images, List<String> keys) {}
|
|
||||||
|
|
||||||
public record Details(String name, String category) {}
|
|
||||||
|
|
||||||
public record Order(List<Long> ids) {}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<AdminView> list() {
|
|
||||||
return products.findAllByOrderByPositionAsc().stream().map(this::toView).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* New items go to the front — the newest work is what's worth showing first, and it saves the
|
|
||||||
* editor a reorder after every upload.
|
|
||||||
*/
|
|
||||||
@PostMapping
|
|
||||||
@Transactional
|
|
||||||
public AdminView create(@RequestParam String name,
|
|
||||||
@RequestParam String category,
|
|
||||||
@RequestParam("photos") List<MultipartFile> files) {
|
|
||||||
String cleanName = required(name, "Please give it a name.");
|
|
||||||
String cleanCategory = required(category, "Please choose a category.");
|
|
||||||
if (files == null || files.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Please add at least one photo.");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> keys = new ArrayList<>();
|
|
||||||
for (MultipartFile file : files) {
|
|
||||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
|
|
||||||
}
|
|
||||||
|
|
||||||
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
|
|
||||||
renumberWithFirst(saved);
|
|
||||||
return toView(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public AdminView describe(@PathVariable Long id, @RequestBody Details details) {
|
|
||||||
Product product = find(id);
|
|
||||||
product.describe(required(details.name(), "Please give it a name."),
|
|
||||||
required(details.category(), "Please choose a category."));
|
|
||||||
return toView(products.save(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/photos")
|
|
||||||
@Transactional
|
|
||||||
public AdminView addPhotos(@PathVariable Long id, @RequestParam("photos") List<MultipartFile> files) {
|
|
||||||
Product product = find(id);
|
|
||||||
if (files == null || files.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Please choose a photo to add.");
|
|
||||||
}
|
|
||||||
List<String> keys = new ArrayList<>(product.getImageKeys());
|
|
||||||
for (MultipartFile file : files) {
|
|
||||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
|
|
||||||
}
|
|
||||||
product.replacePhotos(keys);
|
|
||||||
return toView(products.save(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reordering and removal both arrive as the full list the editor arranged, so the stored order is
|
|
||||||
* whatever they last saw rather than the result of replaying moves.
|
|
||||||
*/
|
|
||||||
@PutMapping("/{id}/photos")
|
|
||||||
@Transactional
|
|
||||||
public AdminView arrangePhotos(@PathVariable Long id, @RequestBody List<String> keys) {
|
|
||||||
Product product = find(id);
|
|
||||||
List<String> existing = product.getImageKeys();
|
|
||||||
List<String> arranged = keys.stream().filter(existing::contains).distinct().toList();
|
|
||||||
if (arranged.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("An item needs at least one photo.");
|
|
||||||
}
|
|
||||||
product.replacePhotos(arranged);
|
|
||||||
return toView(products.save(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
|
||||||
products.delete(find(id));
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
|
|
||||||
@PutMapping("/order")
|
|
||||||
@Transactional
|
|
||||||
public List<AdminView> reorder(@RequestBody Order order) {
|
|
||||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
|
||||||
List<Product> arranged = new ArrayList<>();
|
|
||||||
for (Long id : order.ids()) {
|
|
||||||
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
|
||||||
}
|
|
||||||
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
|
|
||||||
renumber(arranged);
|
|
||||||
return arranged.stream().map(this::toView).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void renumberWithFirst(Product first) {
|
|
||||||
List<Product> arranged = new ArrayList<>();
|
|
||||||
arranged.add(first);
|
|
||||||
products.findAllByOrderByPositionAsc().stream()
|
|
||||||
.filter(p -> !p.getId().equals(first.getId()))
|
|
||||||
.forEach(arranged::add);
|
|
||||||
renumber(arranged);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
|
|
||||||
* swap — forty rows, once in a while, from one editor.
|
|
||||||
*/
|
|
||||||
private void renumber(List<Product> arranged) {
|
|
||||||
int position = 1;
|
|
||||||
for (Product product : arranged) {
|
|
||||||
product.moveTo(position++);
|
|
||||||
}
|
|
||||||
products.saveAll(arranged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Product find(Long id) {
|
|
||||||
return products.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] bytes(MultipartFile file) {
|
|
||||||
try {
|
|
||||||
return file.getBytes();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Could not read the uploaded photo.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String required(String value, String message) {
|
|
||||||
String trimmed = value == null ? "" : value.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException(message);
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reuses the catalogue's URL building so admin and public pages can never disagree about a photo. */
|
|
||||||
private AdminView toView(Product product) {
|
|
||||||
ProductCatalog.ProductView view = catalog.view(product);
|
|
||||||
return new AdminView(view.id(), view.name(), view.category(), product.getPosition(),
|
|
||||||
view.images(), List.copyOf(product.getImageKeys()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import com.itsthevine.web.domain.Category;
|
||||||
|
import com.itsthevine.web.domain.CategoryRepository;
|
||||||
|
import com.itsthevine.web.domain.Product;
|
||||||
|
import com.itsthevine.web.domain.ProductRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editing the catalogue: what's on the products page, in what order, under which filter, with which
|
||||||
|
* photos.
|
||||||
|
*
|
||||||
|
* <p>This is the logic that used to sit in {@code AdminProductController} and
|
||||||
|
* {@code AdminCategoryController} when the admin was a React screen talking JSON. The screen is now
|
||||||
|
* server-rendered forms, and a form can only POST — so "move this up" arrives as an action rather than
|
||||||
|
* as the whole re-ordered list the browser had arranged. The reordering therefore happens here, which is
|
||||||
|
* where it should always have been: the browser was only ever telling us what it had already worked out.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class Catalogue {
|
||||||
|
|
||||||
|
private final ProductRepository products;
|
||||||
|
private final CategoryRepository categories;
|
||||||
|
private final ProductPhotoService photos;
|
||||||
|
private final SitePhotos urls;
|
||||||
|
|
||||||
|
public Catalogue(ProductRepository products, CategoryRepository categories,
|
||||||
|
ProductPhotoService photos, SitePhotos urls) {
|
||||||
|
this.products = products;
|
||||||
|
this.categories = categories;
|
||||||
|
this.photos = photos;
|
||||||
|
this.urls = urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A photo, as the editor needs it: the URL to look at and the key to name it by. Same pairing the
|
||||||
|
* React screen kept as two parallel arrays — a template can just walk the pairs.
|
||||||
|
*/
|
||||||
|
public record Photo(String key, String url) {}
|
||||||
|
|
||||||
|
public record Item(Long id, String name, String category, List<Photo> photos) {}
|
||||||
|
|
||||||
|
/** {@code used} tells the editor whether deleting a filter would strand anything. */
|
||||||
|
public record Filter(Long id, String name, long used) {}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Item> items() {
|
||||||
|
return products.findAllByOrderByPositionAsc().stream().map(this::toItem).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Filter> filters() {
|
||||||
|
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||||
|
return categories.findAllByOrderByPositionAsc().stream()
|
||||||
|
.map(c -> new Filter(c.getId(), c.getName(), count(all, c.getName())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- items ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New items go to the front — the newest work is what's worth showing first, and it saves the editor
|
||||||
|
* a reorder after every upload.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void addItem(String name, String category, List<MultipartFile> files) {
|
||||||
|
String cleanName = required(name, "Please give it a name.");
|
||||||
|
String cleanCategory = required(category, "Please choose a category.");
|
||||||
|
List<MultipartFile> chosen = real(files);
|
||||||
|
if (chosen.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Please add at least one photo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
for (MultipartFile file : chosen) {
|
||||||
|
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
|
||||||
|
}
|
||||||
|
|
||||||
|
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
|
||||||
|
List<Product> arranged = new ArrayList<>();
|
||||||
|
arranged.add(saved);
|
||||||
|
products.findAllByOrderByPositionAsc().stream()
|
||||||
|
.filter(p -> !p.getId().equals(saved.getId()))
|
||||||
|
.forEach(arranged::add);
|
||||||
|
renumber(arranged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void describeItem(Long id, String name, String category) {
|
||||||
|
Product product = item(id);
|
||||||
|
product.describe(required(name, "Please give it a name."),
|
||||||
|
required(category, "Please choose a category."));
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void addPhotos(Long id, List<MultipartFile> files) {
|
||||||
|
Product product = item(id);
|
||||||
|
List<MultipartFile> chosen = real(files);
|
||||||
|
if (chosen.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Please choose a photo to add.");
|
||||||
|
}
|
||||||
|
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||||
|
for (MultipartFile file : chosen) {
|
||||||
|
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
|
||||||
|
}
|
||||||
|
product.replacePhotos(keys);
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Order matters: the first photo is the one the products page leads with. */
|
||||||
|
@Transactional
|
||||||
|
public void movePhoto(Long id, String key, int delta) {
|
||||||
|
Product product = item(id);
|
||||||
|
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||||
|
int at = keys.indexOf(key);
|
||||||
|
if (at < 0) {
|
||||||
|
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
|
||||||
|
}
|
||||||
|
int to = at + delta;
|
||||||
|
if (to < 0 || to >= keys.size()) {
|
||||||
|
// Already at an end. Nothing to do, and nothing to complain about — the button that asked
|
||||||
|
// for this is disabled in the page anyway.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swap(keys, at, to);
|
||||||
|
product.replacePhotos(keys);
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removePhoto(Long id, String key) {
|
||||||
|
Product product = item(id);
|
||||||
|
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||||
|
if (!keys.remove(key)) {
|
||||||
|
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
|
||||||
|
}
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
// The card would have nothing to show. Deleting the item is a different, deliberate act.
|
||||||
|
throw new IllegalArgumentException("An item needs at least one photo.");
|
||||||
|
}
|
||||||
|
product.replacePhotos(keys);
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeItem(Long id) {
|
||||||
|
products.delete(item(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void moveItem(Long id, int delta) {
|
||||||
|
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||||
|
int at = at(all.stream().map(Product::getId).toList(), id, "That item no longer exists.");
|
||||||
|
int to = at + delta;
|
||||||
|
if (to < 0 || to >= all.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swap(all, at, to);
|
||||||
|
renumber(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- filters -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void addFilter(String name) {
|
||||||
|
String clean = required(name, "Please give the category a name.");
|
||||||
|
categories.findByNameIgnoreCase(clean).ifPresent(existing -> {
|
||||||
|
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
|
||||||
|
});
|
||||||
|
int last = categories.findAllByOrderByPositionAsc().stream()
|
||||||
|
.mapToInt(Category::getPosition).max().orElse(0);
|
||||||
|
categories.save(new Category(clean, last + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renaming carries the products with it. They store the category by name, so without this the rename
|
||||||
|
* would orphan everything filed under the old one — it would drop off the filter and reappear at the
|
||||||
|
* end as an unlisted category.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void renameFilter(Long id, String name) {
|
||||||
|
Category category = filter(id);
|
||||||
|
String clean = required(name, "Please give the category a name.");
|
||||||
|
categories.findByNameIgnoreCase(clean)
|
||||||
|
.filter(other -> !other.getId().equals(id))
|
||||||
|
.ifPresent(other -> {
|
||||||
|
throw new IllegalStateException("There's already a " + other.getName() + " category.");
|
||||||
|
});
|
||||||
|
|
||||||
|
String previous = category.getName();
|
||||||
|
category.rename(clean);
|
||||||
|
categories.save(category);
|
||||||
|
|
||||||
|
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
|
||||||
|
filed.forEach(p -> p.describe(p.getName(), clean));
|
||||||
|
products.saveAll(filed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void moveFilter(Long id, int delta) {
|
||||||
|
List<Category> all = categories.findAllByOrderByPositionAsc();
|
||||||
|
int at = at(all.stream().map(Category::getId).toList(), id, "That category no longer exists.");
|
||||||
|
int to = at + delta;
|
||||||
|
if (to < 0 || to >= all.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swap(all, at, to);
|
||||||
|
int position = 1;
|
||||||
|
for (Category category : all) {
|
||||||
|
category.moveTo(position++);
|
||||||
|
}
|
||||||
|
categories.saveAll(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeFilter(Long id) {
|
||||||
|
Category category = filter(id);
|
||||||
|
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
|
||||||
|
if (used > 0) {
|
||||||
|
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
|
||||||
|
// the items behind it.
|
||||||
|
throw new IllegalStateException(used + " item" + (used == 1 ? " is" : "s are")
|
||||||
|
+ " still filed under " + category.getName() + ". Move them first.");
|
||||||
|
}
|
||||||
|
categories.delete(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Reuses the catalogue's URL building so the admin and the shop front agree about a photo. */
|
||||||
|
private Item toItem(Product product) {
|
||||||
|
List<Photo> pictures = product.getImageKeys().stream()
|
||||||
|
.map(key -> new Photo(key, urls.of(key)))
|
||||||
|
.toList();
|
||||||
|
return new Item(product.getId(), product.getName(), product.getCategory(), pictures);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
|
||||||
|
* swap — forty rows, once in a while, from one editor.
|
||||||
|
*/
|
||||||
|
private void renumber(List<Product> arranged) {
|
||||||
|
int position = 1;
|
||||||
|
for (Product product : arranged) {
|
||||||
|
product.moveTo(position++);
|
||||||
|
}
|
||||||
|
products.saveAll(arranged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Product item(Long id) {
|
||||||
|
return products.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Category filter(Long id) {
|
||||||
|
return categories.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int at(List<Long> ids, Long id, String gone) {
|
||||||
|
int at = ids.indexOf(id);
|
||||||
|
if (at < 0) {
|
||||||
|
throw new IllegalArgumentException(gone);
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> void swap(List<T> items, int a, int b) {
|
||||||
|
T held = items.get(a);
|
||||||
|
items.set(a, items.get(b));
|
||||||
|
items.set(b, held);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long count(List<Product> all, String category) {
|
||||||
|
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty file input still posts a part, with no filename and no bytes. */
|
||||||
|
private static List<MultipartFile> real(List<MultipartFile> files) {
|
||||||
|
return files == null ? List.of() : files.stream().filter(f -> !f.isEmpty()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] bytes(MultipartFile file) {
|
||||||
|
try {
|
||||||
|
return file.getBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Could not read the uploaded photo.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, String message) {
|
||||||
|
String trimmed = value == null ? "" : value.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(message);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** The goodie box and catering tables, as a customer reads them. */
|
||||||
|
@RestController
|
||||||
|
public class CateringController {
|
||||||
|
|
||||||
|
private final CateringMenu menu;
|
||||||
|
|
||||||
|
public CateringController(CateringMenu menu) {
|
||||||
|
this.menu = menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/catering")
|
||||||
|
public CateringMenu.MenuView catering() {
|
||||||
|
return menu.menu();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.itsthevine.web.domain.CateringNote;
|
||||||
|
import com.itsthevine.web.domain.CateringNoteRepository;
|
||||||
|
import com.itsthevine.web.domain.CateringPackage;
|
||||||
|
import com.itsthevine.web.domain.CateringPackageRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catering page's brains: which tables to show, in what order, what's in each, and what the
|
||||||
|
* prices read as.
|
||||||
|
*
|
||||||
|
* <p>Both the public page and the admin come through here, so there is one answer to "what does this
|
||||||
|
* table say" — an editor can never arrange something that reads differently once it's live. Prices
|
||||||
|
* arrive as whatever the editor typed and leave as text that's ready to print; {@code Money} is the
|
||||||
|
* only thing that decides either, because how much something costs is the shop's business and not
|
||||||
|
* the browser's.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CateringMenu {
|
||||||
|
|
||||||
|
private static final int MOST_NOTES = 12;
|
||||||
|
|
||||||
|
private final CateringPackageRepository packages;
|
||||||
|
private final CateringNoteRepository notes;
|
||||||
|
|
||||||
|
public CateringMenu(CateringPackageRepository packages, CateringNoteRepository notes) {
|
||||||
|
this.packages = packages;
|
||||||
|
this.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A column. {@code price} is ready to print ("$24") and null when the column doesn't state one;
|
||||||
|
* {@code serves} is roughly how many people it feeds, and null when the label already says.
|
||||||
|
*/
|
||||||
|
public record TierView(Long id, String label, String price, String serves) {}
|
||||||
|
|
||||||
|
/** A line, with one entry per column, in column order. */
|
||||||
|
public record RowView(Long id, String label, List<String> values) {}
|
||||||
|
|
||||||
|
public record PackageView(Long id, String name, String blurb,
|
||||||
|
List<TierView> tiers, List<RowView> rows, List<String> notes) {}
|
||||||
|
|
||||||
|
/** The page: every table, plus the terms that apply to all of them. */
|
||||||
|
public record MenuView(List<PackageView> packages, List<String> notes) {}
|
||||||
|
|
||||||
|
// What the admin screen sends back. A whole table at a time — see CateringPackage#arrange.
|
||||||
|
// `price` is the raw text from the box ("24", "$24.50", ""); Money decides what it means.
|
||||||
|
public record TierEdit(Long id, String label, String price, String serves) {}
|
||||||
|
|
||||||
|
public record RowEdit(Long id, String label, List<String> values) {}
|
||||||
|
|
||||||
|
public record PackageEdit(String name, String blurb, List<TierEdit> tiers, List<RowEdit> rows,
|
||||||
|
List<String> notes) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a customer sees.
|
||||||
|
*
|
||||||
|
* <p>A table with no columns or no lines is left out. Adding a table and filling it in are two
|
||||||
|
* separate acts in the admin, and the gap between them shouldn't put a bare heading on the live
|
||||||
|
* page — an empty price table tells a customer nothing except that we're disorganised.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MenuView menu() {
|
||||||
|
List<PackageView> published = packages.findAllByOrderByPositionAsc().stream()
|
||||||
|
.filter(p -> !p.getTiers().isEmpty() && !p.getRows().isEmpty())
|
||||||
|
.map(CateringMenu::toView)
|
||||||
|
.toList();
|
||||||
|
return new MenuView(published, pageNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the editor sees: the same tables, including any they haven't finished. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MenuView everything() {
|
||||||
|
List<PackageView> all = packages.findAllByOrderByPositionAsc().stream()
|
||||||
|
.map(CateringMenu::toView)
|
||||||
|
.toList();
|
||||||
|
return new MenuView(all, pageNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new, empty table at the end of the page. Columns and lines come next, from the editor. */
|
||||||
|
@Transactional
|
||||||
|
public PackageView add(String name) {
|
||||||
|
int last = packages.findAllByOrderByPositionAsc().stream()
|
||||||
|
.mapToInt(CateringPackage::getPosition).max().orElse(0);
|
||||||
|
return toView(packages.save(new CateringPackage(name, last + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole table as the editor left it. Rejected in full or saved in full: the aggregate checks
|
||||||
|
* every column and line before it touches anything, and the transaction covers the rest.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public PackageView save(Long id, PackageEdit edit) {
|
||||||
|
CateringPackage table = find(id);
|
||||||
|
table.describe(edit.name(), edit.blurb());
|
||||||
|
table.replaceNotes(clean(edit.notes()));
|
||||||
|
table.arrange(
|
||||||
|
orEmpty(edit.tiers()).stream()
|
||||||
|
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price(), t.serves()))
|
||||||
|
.toList(),
|
||||||
|
orEmpty(edit.rows()).stream()
|
||||||
|
.map(r -> new CateringPackage.Line(r.id(), r.label(), orEmpty(r.values())))
|
||||||
|
.toList());
|
||||||
|
return toView(packages.save(table));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void remove(Long id) {
|
||||||
|
packages.delete(find(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
|
||||||
|
@Transactional
|
||||||
|
public List<PackageView> reorder(List<Long> ids) {
|
||||||
|
List<CateringPackage> all = packages.findAllByOrderByPositionAsc();
|
||||||
|
List<CateringPackage> arranged = new ArrayList<>();
|
||||||
|
for (Long id : orEmpty(ids)) {
|
||||||
|
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
||||||
|
}
|
||||||
|
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
|
||||||
|
|
||||||
|
int position = 1;
|
||||||
|
for (CateringPackage table : arranged) {
|
||||||
|
table.moveTo(position++);
|
||||||
|
}
|
||||||
|
packages.saveAll(arranged);
|
||||||
|
return arranged.stream().map(CateringMenu::toView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's own footnotes, replaced by the list the editor is looking at — so removing one is an
|
||||||
|
* omission, exactly as it is everywhere else in this admin.
|
||||||
|
*
|
||||||
|
* <p>The notes that came back are reused in place rather than deleted and re-inserted, so editing
|
||||||
|
* a typo doesn't quietly restamp when the terms were written.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public List<String> replaceNotes(List<String> bodies) {
|
||||||
|
List<String> wanted = clean(bodies);
|
||||||
|
if (wanted.size() > MOST_NOTES) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"That's a lot of small print — " + MOST_NOTES + " notes at most.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CateringNote> existing = notes.findAllByOrderByPositionAsc();
|
||||||
|
List<CateringNote> keeping = new ArrayList<>();
|
||||||
|
for (int i = 0; i < wanted.size(); i++) {
|
||||||
|
CateringNote note = i < existing.size() ? existing.get(i) : new CateringNote(wanted.get(i), i + 1);
|
||||||
|
note.say(wanted.get(i));
|
||||||
|
note.moveTo(i + 1);
|
||||||
|
keeping.add(note);
|
||||||
|
}
|
||||||
|
if (existing.size() > wanted.size()) {
|
||||||
|
notes.deleteAll(existing.subList(wanted.size(), existing.size()));
|
||||||
|
}
|
||||||
|
notes.saveAll(keeping);
|
||||||
|
return keeping.stream().map(CateringNote::getBody).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> pageNotes() {
|
||||||
|
return notes.findAllByOrderByPositionAsc().stream().map(CateringNote::getBody).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CateringPackage find(Long id) {
|
||||||
|
return packages.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("That table no longer exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PackageView toView(CateringPackage table) {
|
||||||
|
List<TierView> tiers = table.getTiers().stream()
|
||||||
|
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice(), t.getServes()))
|
||||||
|
.toList();
|
||||||
|
List<RowView> rows = table.getRows().stream()
|
||||||
|
.map(r -> new RowView(r.getId(), r.getLabel(), r.getValues()))
|
||||||
|
.toList();
|
||||||
|
return new PackageView(table.getId(), table.getName(), table.getBlurb(), tiers, rows, table.getNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blank lines are how a textarea says "nothing here"; they are not notes. */
|
||||||
|
private static List<String> clean(List<String> bodies) {
|
||||||
|
return orEmpty(bodies).stream()
|
||||||
|
.map(body -> body == null ? "" : body.trim())
|
||||||
|
.filter(body -> !body.isEmpty())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A missing JSON array and an empty one mean the same thing to an editor. */
|
||||||
|
private static <T> List<T> orEmpty(List<T> items) {
|
||||||
|
return items == null ? List.of() : items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,31 +4,28 @@ import java.util.Map;
|
|||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.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.
|
* {@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
|
@RestController
|
||||||
@RequestMapping("/api/contact")
|
@RequestMapping("/api/contact")
|
||||||
public class ContactController {
|
public class ContactController {
|
||||||
|
|
||||||
private final ContactService contact;
|
private final Enquiries enquiries;
|
||||||
private final ContactEnquiryRepository enquiries;
|
|
||||||
|
|
||||||
public ContactController(ContactService contact, ContactEnquiryRepository enquiries) {
|
public ContactController(Enquiries enquiries) {
|
||||||
this.contact = contact;
|
|
||||||
this.enquiries = enquiries;
|
this.enquiries = enquiries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,18 +33,7 @@ public class ContactController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<Map<String, Object>> submit(@RequestBody Submission body) {
|
public ResponseEntity<Map<String, Object>> submit(@RequestBody Submission body) {
|
||||||
Enquiry enquiry = Enquiry.of(body.name(), body.email(), body.message());
|
enquiries.receive(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);
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
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
|
* The visitor sees this text, so it must stay the wording the service chose — never a stack trace
|
||||||
* or a generic 500.
|
* or a generic 500.
|
||||||
*/
|
*/
|
||||||
@org.springframework.web.bind.annotation.ExceptionHandler(ContactException.class)
|
@ExceptionHandler(ContactException.class)
|
||||||
public ResponseEntity<Map<String, Object>> handle(ContactException ex) {
|
public ResponseEntity<Map<String, Object>> handle(ContactException ex) {
|
||||||
HttpStatus status = ex.isClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.BAD_GATEWAY;
|
HttpStatus status = ex.isClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.BAD_GATEWAY;
|
||||||
return ResponseEntity.status(status).body(Map.of("error", ex.getMessage()));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.TextStyle;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening times, read two ways: as the list on the page, and as the answer to "are they open now?"
|
||||||
|
*
|
||||||
|
* <p>Both used to be impossible. The hours were three lines of markup, so the only thing the site could do
|
||||||
|
* with them was print them — a visitor at half past two on a Sunday had to work out for themselves that
|
||||||
|
* the shop was shut. They are data now ({@link Shop#WEEK}), and this turns them into both.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class Hours {
|
||||||
|
|
||||||
|
/** One line of the printed list: "Tuesday – Friday", "7:00am – 2:00pm". Closed days say so. */
|
||||||
|
public record Span(String days, String hours, boolean closed) {}
|
||||||
|
|
||||||
|
/** "Open until 2:00pm", or "Closed · opens Tuesday at 7:00am". */
|
||||||
|
public record Status(boolean open, String summary) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The week, with consecutive days that keep the same times collapsed into one line — which is what a
|
||||||
|
* shop sign does, and what the three hand-written lines used to do by hand.
|
||||||
|
*/
|
||||||
|
public List<Span> week() {
|
||||||
|
List<Span> spans = new ArrayList<>();
|
||||||
|
int i = 0;
|
||||||
|
while (i < Shop.READING_ORDER.size()) {
|
||||||
|
DayOfWeek first = Shop.READING_ORDER.get(i);
|
||||||
|
Shop.Opening opening = Shop.WEEK.get(first);
|
||||||
|
|
||||||
|
int j = i;
|
||||||
|
while (j + 1 < Shop.READING_ORDER.size()
|
||||||
|
&& sameHours(opening, Shop.WEEK.get(Shop.READING_ORDER.get(j + 1)))) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
DayOfWeek last = Shop.READING_ORDER.get(j);
|
||||||
|
|
||||||
|
String days = first == last ? name(first) : name(first) + " – " + name(last);
|
||||||
|
spans.add(opening == null
|
||||||
|
? new Span(days, "Closed", true)
|
||||||
|
: new Span(days, time(opening.opens()) + " – " + time(opening.closes()), false));
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Status now() {
|
||||||
|
return at(ZonedDateTime.now(Shop.ZONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param when the moment to answer for; taken as an argument so a test can ask about a Sunday
|
||||||
|
* afternoon without waiting for one
|
||||||
|
*/
|
||||||
|
public Status at(ZonedDateTime when) {
|
||||||
|
LocalTime time = when.toLocalTime();
|
||||||
|
Shop.Opening today = Shop.WEEK.get(when.getDayOfWeek());
|
||||||
|
|
||||||
|
if (today != null && !time.isBefore(today.opens()) && time.isBefore(today.closes())) {
|
||||||
|
return new Status(true, "Open until " + time(today.closes()));
|
||||||
|
}
|
||||||
|
if (today != null && time.isBefore(today.opens())) {
|
||||||
|
return new Status(false, "Opens at " + time(today.opens()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed for the day, so the next answer is on a later day. Seven steps at most, and the shop is
|
||||||
|
// open five of them — the loop cannot run out.
|
||||||
|
for (int ahead = 1; ahead <= 7; ahead++) {
|
||||||
|
DayOfWeek day = when.getDayOfWeek().plus(ahead);
|
||||||
|
Shop.Opening next = Shop.WEEK.get(day);
|
||||||
|
if (next != null) {
|
||||||
|
String when_ = ahead == 1 ? "tomorrow" : name(day);
|
||||||
|
return new Status(false, "Closed · opens " + when_ + " at " + time(next.opens()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Status(false, "Closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean sameHours(Shop.Opening a, Shop.Opening b) {
|
||||||
|
return a == null ? b == null : a.equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String name(DayOfWeek day) {
|
||||||
|
return day.getDisplayName(TextStyle.FULL, Locale.US);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "7:00am", "12:00pm" — the way the shop's own sign writes it, not "07:00". */
|
||||||
|
private static String time(LocalTime at) {
|
||||||
|
int hour = at.getHour() % 12 == 0 ? 12 : at.getHour() % 12;
|
||||||
|
return "%d:%02d%s".formatted(hour, at.getMinute(), at.getHour() < 12 ? "am" : "pm");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package com.itsthevine.web;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.core.io.ResourceLoader;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.stereotype.Controller;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serves {@code index.html} with per-page title/description/OG tags filled in.
|
|
||||||
*
|
|
||||||
* <p>The site used to be server-rendered by Next, so every page came with its own metadata. A plain
|
|
||||||
* SPA would hand crawlers and link-preview scrapers one generic shell for all four pages — a real
|
|
||||||
* loss for a shop that people find by searching. Rendering just the {@code <head>} on the server keeps
|
|
||||||
* that, without dragging SSR (and a Node runtime) into the one-jar model.
|
|
||||||
*
|
|
||||||
* <p>Only the four real routes are listed. Anything else falls through to the platform's SPA
|
|
||||||
* fallback, which is what we want for 404s — no invented metadata for URLs that don't exist.
|
|
||||||
*/
|
|
||||||
@Controller
|
|
||||||
public class PageMetaController {
|
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PageMetaController.class);
|
|
||||||
|
|
||||||
private static final String NAME = "The Vine Coffeehouse + Bakery";
|
|
||||||
private static final String HOME_DESCRIPTION =
|
|
||||||
"A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, "
|
|
||||||
+ "custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.";
|
|
||||||
|
|
||||||
private record PageMeta(String title, String description) {}
|
|
||||||
|
|
||||||
private static final Map<String, PageMeta> PAGES = new LinkedHashMap<>(Map.of(
|
|
||||||
"/", new PageMeta(NAME, HOME_DESCRIPTION),
|
|
||||||
"/products", new PageMeta("Our products · " + NAME,
|
|
||||||
"Cinnamon rolls, caramel rolls, scones, cookie bars, macarons, brownies, pies, and "
|
|
||||||
+ "made-to-order cakes and decorated cookies from The Vine in Princeville, Illinois."),
|
|
||||||
"/history", new PageMeta("Our story · " + NAME,
|
|
||||||
"Morissa Bennett opened The Vine in 2024 at 215 E Main Street in downtown Princeville, "
|
|
||||||
+ "Illinois. We bake in our own kitchen on Main Street."),
|
|
||||||
"/contact", new PageMeta("Contact us · " + NAME,
|
|
||||||
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
|
|
||||||
+ "Illinois. Call (309) 701-0660 or send us a message.")));
|
|
||||||
|
|
||||||
private static final Pattern TITLE = Pattern.compile("<title>.*?</title>", Pattern.DOTALL);
|
|
||||||
|
|
||||||
private final ResourceLoader resourceLoader;
|
|
||||||
private final String baseUrl;
|
|
||||||
|
|
||||||
/** Cached because the file never changes at runtime — it's baked into the jar. */
|
|
||||||
private volatile String template;
|
|
||||||
|
|
||||||
public PageMetaController(ResourceLoader resourceLoader,
|
|
||||||
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
|
|
||||||
this.resourceLoader = resourceLoader;
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(value = {"/", "/products", "/history", "/contact"}, produces = MediaType.TEXT_HTML_VALUE)
|
|
||||||
@ResponseBody
|
|
||||||
public String page(HttpServletRequest request) {
|
|
||||||
String path = request.getRequestURI();
|
|
||||||
PageMeta meta = PAGES.getOrDefault(path, PAGES.get("/"));
|
|
||||||
String html = template();
|
|
||||||
if (html == null) {
|
|
||||||
// No built SPA (backend-only build). Nothing to decorate.
|
|
||||||
return "<!doctype html><title>" + escape(meta.title()) + "</title>";
|
|
||||||
}
|
|
||||||
return render(html, meta, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String render(String html, PageMeta meta, String path) {
|
|
||||||
String out = TITLE.matcher(html).replaceFirst(
|
|
||||||
Matcher.quoteReplacement("<title>" + escape(meta.title()) + "</title>"));
|
|
||||||
out = setMeta(out, "name", "description", meta.description());
|
|
||||||
out = setMeta(out, "property", "og:title", meta.title());
|
|
||||||
out = setMeta(out, "property", "og:description", meta.description());
|
|
||||||
out = setMeta(out, "property", "og:url", baseUrl + ("/".equals(path) ? "" : path));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrites the {@code content} of an existing meta tag. Deliberately does not add missing tags —
|
|
||||||
* index.html carries the full set, so a miss here means the template changed and should be fixed
|
|
||||||
* there rather than papered over with a duplicate tag.
|
|
||||||
*/
|
|
||||||
private static String setMeta(String html, String keyAttr, String key, String value) {
|
|
||||||
Pattern p = Pattern.compile(
|
|
||||||
"(<meta\\s+" + keyAttr + "=\"" + Pattern.quote(key) + "\"\\s+content=\")[^\"]*(\")");
|
|
||||||
Matcher m = p.matcher(html);
|
|
||||||
if (!m.find()) {
|
|
||||||
log.warn("index.html has no <meta {}=\"{}\"> to fill in", keyAttr, key);
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
// Splice by index rather than replaceFirst: replacement strings give $ and \ special meaning,
|
|
||||||
// and these values are prose.
|
|
||||||
return new StringBuilder(html)
|
|
||||||
.replace(m.start(), m.end(), m.group(1) + escape(value) + m.group(2))
|
|
||||||
.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String template() {
|
|
||||||
String cached = template;
|
|
||||||
if (cached == null) {
|
|
||||||
synchronized (this) {
|
|
||||||
if (template == null) {
|
|
||||||
template = load();
|
|
||||||
}
|
|
||||||
cached = template;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cached.isEmpty() ? null : cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String load() {
|
|
||||||
Resource resource = resourceLoader.getResource("classpath:/static/index.html");
|
|
||||||
if (!resource.exists()) {
|
|
||||||
log.warn("no classpath:/static/index.html — serving pages without metadata");
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
try (var in = resource.getInputStream()) {
|
|
||||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("could not read index.html", e);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Escapes for both element text and double-quoted attribute values. */
|
|
||||||
private static String escape(String s) {
|
|
||||||
return s.replace("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("\"", """);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
package com.itsthevine.web;
|
package com.itsthevine.web;
|
||||||
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -32,16 +28,12 @@ public class ProductCatalog {
|
|||||||
|
|
||||||
private final ProductRepository products;
|
private final ProductRepository products;
|
||||||
private final CategoryRepository categories;
|
private final CategoryRepository categories;
|
||||||
private final String assetBaseUrl;
|
private final SitePhotos photos;
|
||||||
|
|
||||||
public ProductCatalog(ProductRepository products,
|
public ProductCatalog(ProductRepository products, CategoryRepository categories, SitePhotos photos) {
|
||||||
CategoryRepository categories,
|
|
||||||
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) {
|
|
||||||
this.products = products;
|
this.products = products;
|
||||||
this.categories = categories;
|
this.categories = categories;
|
||||||
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
|
this.photos = photos;
|
||||||
// up in every image URL on the page.
|
|
||||||
this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ProductView(Long id, String name, String category, List<String> images) {}
|
public record ProductView(Long id, String name, String category, List<String> images) {}
|
||||||
@@ -94,18 +86,6 @@ public class ProductCatalog {
|
|||||||
|
|
||||||
private ProductView toView(Product p) {
|
private ProductView toView(Product p) {
|
||||||
return new ProductView(p.getId(), p.getName(), p.getCategory(),
|
return new ProductView(p.getId(), p.getName(), p.getCategory(),
|
||||||
p.getImageKeys().stream().map(this::imageUrl).toList());
|
p.getImageKeys().stream().map(photos::of).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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shop itself: where it is, how to reach it, and when it is open.
|
||||||
|
*
|
||||||
|
* <p>These facts were prose in three templates. They are now stated once, in Java, because they are read
|
||||||
|
* by more than the eye: the footer and the contact page print them, the header works out from them
|
||||||
|
* whether the shop is open right now, and {@code StructuredData} hands the same opening times to a search
|
||||||
|
* engine — which is how a bakery ends up with its hours on the results page.
|
||||||
|
*
|
||||||
|
* <p>Hardcoded rather than a table with an admin screen, deliberately: opening times change about once a
|
||||||
|
* year, and a table would need a screen, a migration and a way to say "closed Christmas Eve" to be worth
|
||||||
|
* anything. When that day comes this is the one place to lift.
|
||||||
|
*/
|
||||||
|
public final class Shop {
|
||||||
|
|
||||||
|
/** The shop is in Princeville, Illinois; the container runs on UTC, which turns over first. */
|
||||||
|
public static final ZoneId ZONE = ZoneId.of("America/Chicago");
|
||||||
|
|
||||||
|
public static final String NAME = "The Vine Coffeehouse + Bakery";
|
||||||
|
public static final String STREET = "215 E Main Street";
|
||||||
|
public static final String CITY = "Princeville";
|
||||||
|
public static final String STATE = "IL";
|
||||||
|
public static final String POSTCODE = "61559";
|
||||||
|
/** For a tel: link. */
|
||||||
|
public static final String PHONE = "+13097010660";
|
||||||
|
/** For a human. */
|
||||||
|
public static final String PHONE_SPOKEN = "(309) 701-0660";
|
||||||
|
public static final String EMAIL = "[email protected]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the doors are open. Days that aren't here are closed days, which is what makes Sunday and
|
||||||
|
* Monday collapse into one line by themselves.
|
||||||
|
*/
|
||||||
|
public static final Map<DayOfWeek, Opening> WEEK = Map.of(
|
||||||
|
DayOfWeek.TUESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||||
|
DayOfWeek.WEDNESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||||
|
DayOfWeek.THURSDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||||
|
DayOfWeek.FRIDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||||
|
DayOfWeek.SATURDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(12, 0)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The week as it is read, starting on the first open day rather than on Monday — the list on the page
|
||||||
|
* has always begun "Tuesday – Friday", because that is when the shop's week begins.
|
||||||
|
*/
|
||||||
|
public static final List<DayOfWeek> READING_ORDER = List.of(
|
||||||
|
DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY,
|
||||||
|
DayOfWeek.SATURDAY, DayOfWeek.SUNDAY, DayOfWeek.MONDAY);
|
||||||
|
|
||||||
|
public record Opening(LocalTime opens, LocalTime closes) {}
|
||||||
|
|
||||||
|
private Shop() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import net.thebennett.platform.contact.ContactException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site: every page a customer sees, rendered on the server with Thymeleaf.
|
||||||
|
*
|
||||||
|
* <p>This replaced a React SPA and, with it, {@code PageMetaController} — a class whose whole job was
|
||||||
|
* to splice per-page {@code <title>} and OG tags into the SPA's one shell with regular expressions,
|
||||||
|
* because crawlers and link-preview scrapers got nothing useful otherwise. A server-rendered page has
|
||||||
|
* a head of its own, so that machinery is gone rather than ported.
|
||||||
|
*
|
||||||
|
* <p>Every page states its own title and description here, in Java, next to the route that serves it.
|
||||||
|
* The templates only lay them out.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class SiteController {
|
||||||
|
|
||||||
|
private static final String NAME = "The Vine Coffeehouse + Bakery";
|
||||||
|
|
||||||
|
private final ProductCatalog catalog;
|
||||||
|
private final CateringMenu catering;
|
||||||
|
private final Enquiries enquiries;
|
||||||
|
private final SitePhotos photos;
|
||||||
|
|
||||||
|
public SiteController(ProductCatalog catalog, CateringMenu catering, Enquiries enquiries, SitePhotos photos) {
|
||||||
|
this.catalog = catalog;
|
||||||
|
this.catering = catering;
|
||||||
|
this.enquiries = enquiries;
|
||||||
|
this.photos = photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/")
|
||||||
|
public String home(Model model) {
|
||||||
|
meta(model, "/", NAME,
|
||||||
|
"A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake "
|
||||||
|
+ "pastries, custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, "
|
||||||
|
+ "and coffee.");
|
||||||
|
// The hero photo is the largest thing on the page and the first thing you see; telling the
|
||||||
|
// browser about it in the head starts it a round trip sooner.
|
||||||
|
model.addAttribute("preload", photos.of("gallery/Outside.webp"));
|
||||||
|
return "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param category a stored category, or absent/"All" for the whole catalogue. A query parameter
|
||||||
|
* rather than a click handler: the filtered page is now a real URL you can send
|
||||||
|
* someone, and the filter is applied by the same code that answers /api/products.
|
||||||
|
*/
|
||||||
|
@GetMapping("/products")
|
||||||
|
public String products(@RequestParam(required = false) String category, Model model) {
|
||||||
|
String selected = category == null || category.isBlank() ? ProductCatalog.ALL : category;
|
||||||
|
meta(model, "/products", "Our products · " + NAME,
|
||||||
|
"Cinnamon rolls, caramel rolls, scones, cookie bars, macarons, brownies, pies, and "
|
||||||
|
+ "made-to-order cakes and decorated cookies from The Vine in Princeville, Illinois.");
|
||||||
|
model.addAttribute("categories", catalog.categories());
|
||||||
|
model.addAttribute("selected", selected);
|
||||||
|
model.addAttribute("products", catalog.list(selected));
|
||||||
|
return "products";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/catering")
|
||||||
|
public String catering(Model model) {
|
||||||
|
meta(model, "/catering", "Goodie boxes & catering · " + NAME,
|
||||||
|
"Goodie boxes for the office, party packages, and wedding cakes and desserts from The "
|
||||||
|
+ "Vine in Princeville, Illinois — what each size includes, and what it costs.");
|
||||||
|
model.addAttribute("menu", catering.menu());
|
||||||
|
return "catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history")
|
||||||
|
public String history(Model model) {
|
||||||
|
meta(model, "/history", "Our story · " + NAME,
|
||||||
|
"Morissa Bennett opened The Vine in 2024 at 215 E Main Street in downtown Princeville, "
|
||||||
|
+ "Illinois. We bake in our own kitchen on Main Street.");
|
||||||
|
return "history";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param about which catering table they came from, if they arrived by one of that page's buttons.
|
||||||
|
* The message box starts with the question already half-asked — the alternative is a
|
||||||
|
* blank box and an enquiry that says "how much?" with no way to tell what about.
|
||||||
|
*/
|
||||||
|
@GetMapping("/contact")
|
||||||
|
public String contact(@RequestParam(required = false) String about, Model model) {
|
||||||
|
contactMeta(model);
|
||||||
|
// Matched against the real table names rather than echoed: this text ends up in a box on the
|
||||||
|
// page, and a query parameter is whatever a link says it is. Thymeleaf would escape it, but a
|
||||||
|
// link that puts words of someone else's choosing in front of a customer is still not a link we
|
||||||
|
// want to work.
|
||||||
|
catering.menu().packages().stream()
|
||||||
|
.map(CateringMenu.PackageView::name)
|
||||||
|
.filter(name -> name.equalsIgnoreCase(about))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(name -> model.addAttribute("message",
|
||||||
|
"I'd like to ask about " + name.toLowerCase() + " catering — "));
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form posts here and gets a page back — no JavaScript involved in sending an enquiry.
|
||||||
|
*
|
||||||
|
* <p>It renders rather than redirects on both outcomes, deliberately. A failed send has to come
|
||||||
|
* back with what the visitor typed still in the boxes: they wrote it once, and the failure is ours
|
||||||
|
* (a refused relay), not theirs. On success the fields are cleared and the message replaces them.
|
||||||
|
*
|
||||||
|
* @param website the trap. It is a real field with a plausible name, hidden from anyone reading the
|
||||||
|
* page and skipped by the tab key — a person cannot fill it in, and the crawlers that
|
||||||
|
* post to every form they find fill in everything. A filled one is answered with the
|
||||||
|
* same thank-you a person gets: telling a bot it failed only teaches it to try again,
|
||||||
|
* and the enquiry is simply never recorded or sent.
|
||||||
|
*/
|
||||||
|
@PostMapping("/contact")
|
||||||
|
public String submit(@RequestParam String name,
|
||||||
|
@RequestParam String email,
|
||||||
|
@RequestParam String message,
|
||||||
|
@RequestParam(required = false) String website,
|
||||||
|
Model model) {
|
||||||
|
contactMeta(model);
|
||||||
|
if (website != null && !website.isBlank()) {
|
||||||
|
model.addAttribute("sent", true);
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
enquiries.receive(name, email, message);
|
||||||
|
model.addAttribute("sent", true);
|
||||||
|
} catch (ContactException e) {
|
||||||
|
// The service wrote this sentence for the visitor to read; don't replace it with a status
|
||||||
|
// code or a stack trace.
|
||||||
|
model.addAttribute("error", e.getMessage());
|
||||||
|
model.addAttribute("name", name);
|
||||||
|
model.addAttribute("email", email);
|
||||||
|
model.addAttribute("message", message);
|
||||||
|
}
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void contactMeta(Model model) {
|
||||||
|
meta(model, "/contact", "Contact us · " + NAME,
|
||||||
|
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
|
||||||
|
+ "Illinois. Call (309) 701-0660 or send us a message.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param path the route, so the head can build an absolute og:url for the scrapers */
|
||||||
|
private static void meta(Model model, String path, String title, String description) {
|
||||||
|
model.addAttribute("path", path);
|
||||||
|
model.addAttribute("title", title);
|
||||||
|
model.addAttribute("description", description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The handful of things every page's chrome needs, added to the model once instead of by each handler.
|
||||||
|
*
|
||||||
|
* <p>Not scoped to {@link SiteController}: the error pages wear the same chrome, and Boot renders those
|
||||||
|
* through its own controller. The JSON controllers get these attributes too and ignore them, which
|
||||||
|
* costs nothing — a {@code @ResponseBody} method never looks at the model.
|
||||||
|
*/
|
||||||
|
@ControllerAdvice
|
||||||
|
public class SiteModel {
|
||||||
|
|
||||||
|
private final SitePhotos photos;
|
||||||
|
private final Hours hours;
|
||||||
|
private final StructuredData structuredData;
|
||||||
|
private final String baseUrl;
|
||||||
|
private final String build;
|
||||||
|
|
||||||
|
public SiteModel(SitePhotos photos, Hours hours, StructuredData structuredData,
|
||||||
|
@Value("${site.base-url:https://itsthevine.com}") String baseUrl,
|
||||||
|
@Value("${site.build:dev}") String build) {
|
||||||
|
this.photos = photos;
|
||||||
|
this.hours = hours;
|
||||||
|
this.structuredData = structuredData;
|
||||||
|
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||||
|
this.build = build;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The opening times as the page prints them — see Hours#week. */
|
||||||
|
@ModelAttribute("hours")
|
||||||
|
public java.util.List<Hours.Span> hours() {
|
||||||
|
return hours.week();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the shop is open at this moment, for the line in the header. */
|
||||||
|
@ModelAttribute("openNow")
|
||||||
|
public Hours.Status openNow() {
|
||||||
|
return hours.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shop's own facts, so no template states an address or a phone number itself. Four attributes
|
||||||
|
// rather than one object because a template reading `${shopStreet}` needs no explaining.
|
||||||
|
|
||||||
|
@ModelAttribute("shopStreet")
|
||||||
|
public String shopStreet() {
|
||||||
|
return Shop.STREET;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("shopTown")
|
||||||
|
public String shopTown() {
|
||||||
|
return Shop.CITY + ", " + Shop.STATE + " " + Shop.POSTCODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("shopPhone")
|
||||||
|
public String shopPhone() {
|
||||||
|
return Shop.PHONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("shopPhoneSpoken")
|
||||||
|
public String shopPhoneSpoken() {
|
||||||
|
return Shop.PHONE_SPOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("shopEmail")
|
||||||
|
public String shopEmail() {
|
||||||
|
return Shop.EMAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** schema.org JSON-LD for the head. Written by Jackson; see StructuredData. */
|
||||||
|
@ModelAttribute("structuredData")
|
||||||
|
public String structuredData() {
|
||||||
|
return structuredData.bakery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a shared link should show. */
|
||||||
|
@ModelAttribute("shareImage")
|
||||||
|
public String shareImage() {
|
||||||
|
return photos.of("gallery/Outside.webp");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets a template ask for a photo by key: {@code ${photos.of('gallery/Outside.webp')}}. */
|
||||||
|
@ModelAttribute("photos")
|
||||||
|
public SitePhotos photos() {
|
||||||
|
return photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute URLs for og:url, which only means anything to a scraper if it's absolute. */
|
||||||
|
@ModelAttribute("baseUrl")
|
||||||
|
public String baseUrl() {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("assetOrigin")
|
||||||
|
public String assetOrigin() {
|
||||||
|
return photos.origin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hung on the stylesheet URL as {@code ?v=…}.
|
||||||
|
*
|
||||||
|
* <p>The SPA's bundles had content hashes in their filenames; one hand-written stylesheet does not,
|
||||||
|
* so without this a visitor keeps whatever CSS they cached before the deploy — new markup, old
|
||||||
|
* rules. The deployment passes the commit sha; a dev run says "dev".
|
||||||
|
*/
|
||||||
|
@ModelAttribute("build")
|
||||||
|
public String build() {
|
||||||
|
return build;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The footer's copyright year. */
|
||||||
|
@ModelAttribute("year")
|
||||||
|
public int year() {
|
||||||
|
return ZonedDateTime.now(Shop.ZONE).getYear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a photo lives: {@code of("gallery/Outside.webp")} → the absolute bucket URL.
|
||||||
|
*
|
||||||
|
* <p>Photos are in a public MinIO bucket rather than the image — 50 MB of JPEGs has no business inside
|
||||||
|
* a container we redeploy on every commit — so the bucket's address is deployment configuration and the
|
||||||
|
* absolute URL is built here rather than stored on anything. Both the catalogue and the templates use
|
||||||
|
* this, so an editor can never arrange a photo that resolves differently once it's live.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SitePhotos {
|
||||||
|
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public SitePhotos(@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String baseUrl) {
|
||||||
|
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
|
||||||
|
// up in every image URL on the page.
|
||||||
|
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some photo filenames contain spaces ("Cinnamon Rolls.webp"), and a raw space in a URL doesn't
|
||||||
|
* fetch — so each path segment is encoded. {@code URLEncoder} is form-encoding, which differs from
|
||||||
|
* path-encoding in exactly one way that matters here: it turns a space into '+'.
|
||||||
|
*/
|
||||||
|
public String of(String key) {
|
||||||
|
String encoded = Arrays.stream(key.replaceAll("^/+", "").split("/"))
|
||||||
|
.map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"))
|
||||||
|
.collect(Collectors.joining("/"));
|
||||||
|
return baseUrl + "/images/" + encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just the scheme and host, for the {@code preconnect} in the page head: opening that connection
|
||||||
|
* during the head saves the hero image a round trip.
|
||||||
|
*/
|
||||||
|
public String origin() {
|
||||||
|
URI uri = URI.create(baseUrl);
|
||||||
|
return uri.getScheme() + "://" + uri.getAuthority();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.format.TextStyle;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shop, described for machines: a schema.org Bakery in the page head.
|
||||||
|
*
|
||||||
|
* <p>This is how a search engine learns that itsthevine.com is a bakery in Princeville with a phone number
|
||||||
|
* and Saturday hours — the difference between a blue link and a listing that shows "Open ⋅ closes 2pm".
|
||||||
|
* For a shop whose customers find it by searching its town, that is worth more than anything else on this
|
||||||
|
* page.
|
||||||
|
*
|
||||||
|
* <p>Built from {@link Shop} and rendered once in the head, so the hours a crawler reads cannot drift from
|
||||||
|
* the ones a visitor reads. Serialised with Jackson rather than string-built: every value here is a
|
||||||
|
* constant of ours today, but the one day somebody interpolates a name into it, hand-built JSON in a
|
||||||
|
* {@code <script>} is an injection.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StructuredData {
|
||||||
|
|
||||||
|
private final ObjectMapper json;
|
||||||
|
private final SitePhotos photos;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public StructuredData(ObjectMapper json, SitePhotos photos,
|
||||||
|
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
|
||||||
|
this.json = json;
|
||||||
|
this.photos = photos;
|
||||||
|
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String bakery() {
|
||||||
|
Map<String, Object> shop = new LinkedHashMap<>();
|
||||||
|
shop.put("@context", "https://schema.org");
|
||||||
|
shop.put("@type", "Bakery");
|
||||||
|
shop.put("name", Shop.NAME);
|
||||||
|
shop.put("url", baseUrl);
|
||||||
|
shop.put("image", photos.of("gallery/Outside.webp"));
|
||||||
|
shop.put("telephone", Shop.PHONE);
|
||||||
|
shop.put("email", Shop.EMAIL);
|
||||||
|
// "$" rather than a number: schema.org wants a band, and ours is one coffee to a wedding cake.
|
||||||
|
shop.put("priceRange", "$");
|
||||||
|
shop.put("servesCuisine", List.of("Bakery", "Coffee", "Sandwiches"));
|
||||||
|
shop.put("address", Map.of(
|
||||||
|
"@type", "PostalAddress",
|
||||||
|
"streetAddress", Shop.STREET,
|
||||||
|
"addressLocality", Shop.CITY,
|
||||||
|
"addressRegion", Shop.STATE,
|
||||||
|
"postalCode", Shop.POSTCODE,
|
||||||
|
"addressCountry", "US"));
|
||||||
|
shop.put("openingHoursSpecification", openingHours());
|
||||||
|
|
||||||
|
// Jackson 3 (tools.jackson, what Boot 4 ships) throws unchecked, and there is nothing to catch
|
||||||
|
// anyway: the input is a map of our own constants.
|
||||||
|
return json.writeValueAsString(shop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Days that keep the same times are given as one specification with several days on it, which is both
|
||||||
|
* shorter and what the vocabulary is for.
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> openingHours() {
|
||||||
|
Map<Shop.Opening, List<String>> byHours = new TreeMap<>(
|
||||||
|
(a, b) -> a.opens().equals(b.opens()) ? a.closes().compareTo(b.closes()) : a.opens().compareTo(b.opens()));
|
||||||
|
for (DayOfWeek day : Shop.READING_ORDER) {
|
||||||
|
Shop.Opening opening = Shop.WEEK.get(day);
|
||||||
|
if (opening != null) {
|
||||||
|
byHours.computeIfAbsent(opening, o -> new java.util.ArrayList<>())
|
||||||
|
.add(day.getDisplayName(TextStyle.FULL, Locale.US));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return byHours.entrySet().stream()
|
||||||
|
.map(entry -> Map.<String, Object>of(
|
||||||
|
"@type", "OpeningHoursSpecification",
|
||||||
|
"dayOfWeek", entry.getValue(),
|
||||||
|
"opens", entry.getKey().opens().toString(),
|
||||||
|
"closes", entry.getKey().closes().toString()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import net.thebennett.platform.data.BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A footnote for the catering page as a whole — the terms that apply whichever table you were
|
||||||
|
* reading, like "we're happy to make changes, the price may change with them".
|
||||||
|
*
|
||||||
|
* <p>Its own table rather than a note on a package, because these outlive any one table: delete the
|
||||||
|
* wedding package and the page still has terms.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "catering_note")
|
||||||
|
public class CateringNote extends BaseEntity {
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 600)
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
@Column(name = "position", nullable = false)
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
protected CateringNote() {
|
||||||
|
// for JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
public CateringNote(String body, int position) {
|
||||||
|
say(body);
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void say(String body) {
|
||||||
|
this.body = Text.required(body, 600, "A note with nothing in it — delete it rather than blanking it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void moveTo(int position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBody() {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface CateringNoteRepository extends JpaRepository<CateringNote, Long> {
|
||||||
|
|
||||||
|
List<CateringNote> findAllByOrderByPositionAsc();
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.CollectionTable;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.ElementCollection;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.OrderColumn;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import net.thebennett.platform.data.BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One price table on the catering page — "Office", "Parties", "Weddings".
|
||||||
|
*
|
||||||
|
* <p>The bakery keeps these as a spreadsheet, so a spreadsheet is what this models: {@link
|
||||||
|
* CateringTier}s are the columns (a size, and what it costs) and {@link CateringRow}s are the lines
|
||||||
|
* (a baked good, and how much of it each column includes). A line holds one value per column, in
|
||||||
|
* column order.
|
||||||
|
*
|
||||||
|
* <p>That alignment is the whole reason this is an aggregate rather than three tables edited
|
||||||
|
* separately. A column and the values under it only mean anything together — drop the middle column
|
||||||
|
* on its own and every remaining value shifts one place left, so the Large box silently starts
|
||||||
|
* advertising the Medium box's contents at the Large price. Only this class can rearrange a table,
|
||||||
|
* and it will not accept an arrangement whose lines and columns disagree.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "catering_package")
|
||||||
|
public class CateringPackage extends BaseEntity {
|
||||||
|
|
||||||
|
/** Wider than this doesn't fit a phone, and these tables are read on phones. */
|
||||||
|
private static final int MOST_COLUMNS = 8;
|
||||||
|
private static final int MOST_LINES = 40;
|
||||||
|
private static final int MOST_NOTES = 12;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(length = 400)
|
||||||
|
private String blurb;
|
||||||
|
|
||||||
|
@Column(name = "position", nullable = false)
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
/** The rules under this table: minimums, what can't be mixed, how delivery is charged. */
|
||||||
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
|
@CollectionTable(name = "catering_package_note", joinColumns = @JoinColumn(name = "package_id"))
|
||||||
|
@OrderColumn(name = "position")
|
||||||
|
@Column(name = "body", nullable = false, length = 600)
|
||||||
|
private List<String> notes = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("position")
|
||||||
|
private List<CateringTier> tiers = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "cateringPackage", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("position")
|
||||||
|
private List<CateringRow> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
protected CateringPackage() {
|
||||||
|
// for JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
public CateringPackage(String name, int position) {
|
||||||
|
describe(name, null);
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A column as the editor left it — its heading and its price. A null {@code id} is one they just
|
||||||
|
* added. (Named for the heading rather than the column so as not to shadow {@code @Column}.)
|
||||||
|
*/
|
||||||
|
public record Heading(Long id, String label, String price, String serves) {}
|
||||||
|
|
||||||
|
/** A line as the editor left it, with one value per column — blanks included. */
|
||||||
|
public record Line(Long id, String label, List<String> values) {}
|
||||||
|
|
||||||
|
public final void describe(String name, String blurb) {
|
||||||
|
this.name = Text.required(name, 120, "Please give the table a name, like \"Weddings\".");
|
||||||
|
String trimmed = Text.optional(blurb, 400);
|
||||||
|
this.blurb = trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void moveTo(int position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void replaceNotes(List<String> replacements) {
|
||||||
|
if (replacements.size() > MOST_NOTES) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"That's a lot of small print — " + MOST_NOTES + " notes per table at most.");
|
||||||
|
}
|
||||||
|
List<String> cleaned = replacements.stream()
|
||||||
|
.map(note -> Text.required(note, 600, "One of the notes is empty — delete it rather than blanking it."))
|
||||||
|
.toList();
|
||||||
|
this.notes.clear();
|
||||||
|
this.notes.addAll(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the table exactly this: these columns in this order, and these lines, each carrying one
|
||||||
|
* value per column.
|
||||||
|
*
|
||||||
|
* <p>The whole table arrives at once because that is the only way the editor can move a column and
|
||||||
|
* take its values with it. Anything they left out is deleted, anything carrying an id keeps its
|
||||||
|
* identity, and positions are renumbered from the order they arrived in rather than trusted from
|
||||||
|
* the request — so what's stored is what they were looking at when they hit save.
|
||||||
|
*/
|
||||||
|
public void arrange(List<Heading> columns, List<Line> lines) {
|
||||||
|
if (columns.size() > MOST_COLUMNS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"A table can have at most " + MOST_COLUMNS + " columns and still be readable on a phone.");
|
||||||
|
}
|
||||||
|
if (lines.size() > MOST_LINES) {
|
||||||
|
throw new IllegalArgumentException("A table can have at most " + MOST_LINES + " lines.");
|
||||||
|
}
|
||||||
|
for (Line line : lines) {
|
||||||
|
if (line.values().size() != columns.size()) {
|
||||||
|
// Not a message an editor should ever see: the screen sends whole tables. Worth saying
|
||||||
|
// out loud anyway, because the alternative is a table that quietly means something else.
|
||||||
|
throw new IllegalArgumentException("\"" + line.label() + "\" has " + line.values().size()
|
||||||
|
+ " entries but the table has " + columns.size()
|
||||||
|
+ " columns. Reload the page and try that again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CateringTier> arrangedTiers = new ArrayList<>();
|
||||||
|
int columnNumber = 1;
|
||||||
|
for (Heading column : columns) {
|
||||||
|
CateringTier tier = column.id() == null ? new CateringTier(this) : tier(column.id());
|
||||||
|
tier.describe(column.label(), column.price(), column.serves());
|
||||||
|
tier.moveTo(columnNumber++);
|
||||||
|
arrangedTiers.add(tier);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CateringRow> arrangedRows = new ArrayList<>();
|
||||||
|
int lineNumber = 1;
|
||||||
|
for (Line line : lines) {
|
||||||
|
CateringRow row = line.id() == null ? new CateringRow(this) : row(line.id());
|
||||||
|
row.describe(line.label());
|
||||||
|
row.replaceValues(line.values());
|
||||||
|
row.moveTo(lineNumber++);
|
||||||
|
arrangedRows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both collections are rewritten only after every column and line has been accepted, so a
|
||||||
|
// rejected edit leaves the table exactly as it was.
|
||||||
|
settle(tiers, arrangedTiers, Comparator.comparingInt(CateringTier::getPosition));
|
||||||
|
settle(rows, arrangedRows, Comparator.comparingInt(CateringRow::getPosition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep what was arranged, drop what wasn't. Removal from the collection is what deletes the row —
|
||||||
|
* these are {@code orphanRemoval} associations — so the omitted ones need no further handling.
|
||||||
|
*/
|
||||||
|
private static <T> void settle(List<T> stored, List<T> arranged, Comparator<T> byPosition) {
|
||||||
|
stored.removeIf(item -> !holds(arranged, item));
|
||||||
|
arranged.stream().filter(item -> !holds(stored, item)).forEach(stored::add);
|
||||||
|
stored.sort(byPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity, deliberately: entities here inherit no {@code equals}, and two freshly built columns
|
||||||
|
* with the same heading are two different columns.
|
||||||
|
*/
|
||||||
|
private static boolean holds(List<?> items, Object item) {
|
||||||
|
return items.stream().anyMatch(candidate -> candidate == item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CateringTier tier(Long id) {
|
||||||
|
return tiers.stream().filter(t -> id.equals(t.getId())).findFirst().orElseThrow(this::changedUnderneath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CateringRow row(Long id) {
|
||||||
|
return rows.stream().filter(r -> id.equals(r.getId())).findFirst().orElseThrow(this::changedUnderneath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IllegalArgumentException changedUnderneath() {
|
||||||
|
return new IllegalArgumentException(
|
||||||
|
"Part of the " + name + " table isn't there any more. Reload the page to see it as it is now.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBlurb() {
|
||||||
|
return blurb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getNotes() {
|
||||||
|
return List.copyOf(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CateringTier> getTiers() {
|
||||||
|
return List.copyOf(tiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CateringRow> getRows() {
|
||||||
|
return List.copyOf(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface CateringPackageRepository extends JpaRepository<CateringPackage, Long> {
|
||||||
|
|
||||||
|
List<CateringPackage> findAllByOrderByPositionAsc();
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import jakarta.persistence.CollectionTable;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.ElementCollection;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OrderColumn;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import net.thebennett.platform.data.BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of a catering table: a baked good, and how much of it each column includes.
|
||||||
|
*
|
||||||
|
* <p>The values are positional — index 0 belongs to the first column — and there is always exactly
|
||||||
|
* one per column, blanks included. {@link CateringPackage} is the only thing that can set them,
|
||||||
|
* because it is the only thing that knows how many columns there are.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "catering_row")
|
||||||
|
public class CateringRow extends BaseEntity {
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "package_id", nullable = false)
|
||||||
|
private CateringPackage cateringPackage;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry per column, in column order. An empty string is a cell the bakery hasn't filled in —
|
||||||
|
* the spreadsheet has several — so blanks are stored rather than dropped, which is what keeps the
|
||||||
|
* list aligned with the columns.
|
||||||
|
*/
|
||||||
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
|
@CollectionTable(name = "catering_row_value", joinColumns = @JoinColumn(name = "row_id"))
|
||||||
|
@OrderColumn(name = "position")
|
||||||
|
@Column(name = "value", nullable = false, length = 300)
|
||||||
|
private List<String> values = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "position", nullable = false)
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
protected CateringRow() {
|
||||||
|
// for JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
CateringRow(CateringPackage cateringPackage) {
|
||||||
|
this.cateringPackage = cateringPackage;
|
||||||
|
}
|
||||||
|
|
||||||
|
void describe(String label) {
|
||||||
|
this.label = Text.required(label, 200, "Every line needs a name — what is it the customer gets?");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaced wholesale; the package has already checked there is one value per column. */
|
||||||
|
void replaceValues(List<String> replacements) {
|
||||||
|
List<String> cleaned = replacements.stream().map(value -> Text.optional(value, 300)).toList();
|
||||||
|
this.values.clear();
|
||||||
|
this.values.addAll(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
void moveTo(int position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getValues() {
|
||||||
|
return List.copyOf(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import net.thebennett.platform.data.BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One column of a catering table: a size, and what it costs.
|
||||||
|
*
|
||||||
|
* <p>Only {@link CateringPackage} can change one. A tier means nothing apart from the table it sits
|
||||||
|
* in — its price is read against the row values beside it — so the package is the only thing allowed
|
||||||
|
* to rearrange them, which is what keeps a row's values and its columns the same length.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "catering_tier")
|
||||||
|
public class CateringTier extends BaseEntity {
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "package_id", nullable = false)
|
||||||
|
private CateringPackage cateringPackage;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
/** Whole cents, and nullable — see {@link Money}. */
|
||||||
|
@Column(name = "price_cents")
|
||||||
|
private Integer priceCents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roughly how many people this column feeds — "About 6–8 people".
|
||||||
|
*
|
||||||
|
* <p>Free text, and optional. A column named "Small" says nothing about how many it feeds, which is
|
||||||
|
* the question anyone ordering for an office actually has; a column named "125 people" has already
|
||||||
|
* answered it and leaves this blank rather than saying it twice.
|
||||||
|
*/
|
||||||
|
@Column(length = 80)
|
||||||
|
private String serves;
|
||||||
|
|
||||||
|
@Column(name = "position", nullable = false)
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
protected CateringTier() {
|
||||||
|
// for JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
CateringTier(CateringPackage cateringPackage) {
|
||||||
|
this.cateringPackage = cateringPackage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param price as the editor typed it; empty for a column that doesn't state one
|
||||||
|
* @param serves roughly how many people, or empty
|
||||||
|
*/
|
||||||
|
void describe(String label, String price, String serves) {
|
||||||
|
this.label = Text.required(label, 120,
|
||||||
|
"Every column needs a heading — a size like \"Large\", or who it feeds like \"15–20 people\".");
|
||||||
|
this.priceCents = Money.cents(price);
|
||||||
|
String trimmed = Text.optional(serves, 80);
|
||||||
|
this.serves = trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void moveTo(int position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPriceCents() {
|
||||||
|
return priceCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the column's price reads as — "$24", or null when it doesn't state one. */
|
||||||
|
public String getPrice() {
|
||||||
|
return Money.format(priceCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServes() {
|
||||||
|
return serves;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a price is, in one place: how the bakery types one in, how it's stored, and how it's printed.
|
||||||
|
*
|
||||||
|
* <p>Editors type "24", "$24", "24.50" or nothing at all, so the parsing lives here rather than in the
|
||||||
|
* browser — a price the server didn't agree to is not a price, and {@code Number(text) * 100} gives
|
||||||
|
* 2410.0000000000005 for "24.10" on the way past. Stored as whole cents, because money is not a
|
||||||
|
* floating-point number.
|
||||||
|
*/
|
||||||
|
public final class Money {
|
||||||
|
|
||||||
|
/** Ten thousand dollars. Above this it's a decimal point in the wrong place, not a wedding. */
|
||||||
|
private static final int MOST_ANYTHING_COSTS = 1_000_000;
|
||||||
|
|
||||||
|
private Money() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param typed what the editor put in the box
|
||||||
|
* @return whole cents, or null for a column that doesn't state a price
|
||||||
|
*/
|
||||||
|
public static Integer cents(String typed) {
|
||||||
|
String cleaned = typed == null ? "" : typed.replace("$", "").replace(",", "").replace(" ", "").trim();
|
||||||
|
if (cleaned.isEmpty()) {
|
||||||
|
// Not an error: "ask us" is a legitimate thing for a column to say, and it says it by
|
||||||
|
// leaving the price empty.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal amount;
|
||||||
|
try {
|
||||||
|
amount = new BigDecimal(cleaned);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new IllegalArgumentException("\"" + typed
|
||||||
|
+ "\" isn't a price. Leave it empty if that column doesn't have one.");
|
||||||
|
}
|
||||||
|
if (amount.scale() > 2) {
|
||||||
|
throw new IllegalArgumentException("Prices go to the cent — \"" + typed + "\" is finer than that.");
|
||||||
|
}
|
||||||
|
if (amount.signum() < 0) {
|
||||||
|
throw new IllegalArgumentException("A price can't be less than nothing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
long asCents = amount.movePointRight(2).longValueExact();
|
||||||
|
if (asCents > MOST_ANYTHING_COSTS) {
|
||||||
|
throw new IllegalArgumentException("That price is over $10,000 — check the decimal point.");
|
||||||
|
}
|
||||||
|
return (int) asCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "$24", "$1,250", "$24.50" — never "$24.0", and null stays null.
|
||||||
|
*
|
||||||
|
* <p>{@code Locale.US} rather than the default: the price of a cake in Princeville, Illinois does
|
||||||
|
* not depend on which locale the container started in, and a default of de-DE would print
|
||||||
|
* "$1.250" for one thousand two hundred and fifty dollars.
|
||||||
|
*/
|
||||||
|
public static String format(Integer cents) {
|
||||||
|
if (cents == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int dollars = cents / 100;
|
||||||
|
int change = cents % 100;
|
||||||
|
return change == 0
|
||||||
|
? String.format(Locale.US, "$%,d", dollars)
|
||||||
|
: String.format(Locale.US, "$%,d.%02d", dollars, change);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.itsthevine.web.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trimming and length rules for editor-supplied prose.
|
||||||
|
*
|
||||||
|
* <p>Every one of these limits is a column width, so the choice is between checking them here and
|
||||||
|
* letting Postgres reject the insert — which reaches the editor as an unexplained 500 with their
|
||||||
|
* afternoon's work still unsaved. The message names the offending text, because a catering table is
|
||||||
|
* a grid of thirty small cells and "too long" alone doesn't say which one.
|
||||||
|
*/
|
||||||
|
final class Text {
|
||||||
|
|
||||||
|
private Text() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String required(String value, int max, String missing) {
|
||||||
|
String trimmed = value == null ? "" : value.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(missing);
|
||||||
|
}
|
||||||
|
return capped(trimmed, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trimmed, possibly empty — a blank cell is a real thing to want. */
|
||||||
|
static String optional(String value, int max) {
|
||||||
|
return capped(value == null ? "" : value.trim(), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String capped(String text, int max) {
|
||||||
|
if (text.length() > max) {
|
||||||
|
String preview = text.substring(0, Math.min(40, text.length()));
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"\"" + preview + "…\" is longer than the " + max + " characters that fit there.");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,11 @@ spring:
|
|||||||
platform:
|
platform:
|
||||||
web:
|
web:
|
||||||
spa:
|
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:
|
data:
|
||||||
auditing:
|
auditing:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -69,6 +73,10 @@ platform:
|
|||||||
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
||||||
site:
|
site:
|
||||||
base-url: ${SITE_BASE_URL:https://itsthevine.com}
|
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:
|
assets:
|
||||||
# Where uploaded photos land. The key stored on a product is bucket-relative and excludes the
|
# 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.
|
# prefix, because ProductCatalog re-adds `/images/` when it builds the public URL.
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
-- Goodie boxes and catering: the Office / Parties / Weddings price tables the bakery hands out.
|
||||||
|
--
|
||||||
|
-- These arrived as a spreadsheet, and a spreadsheet is what they are, so that is what this models:
|
||||||
|
-- a PACKAGE is one table on the page, its TIERS are the columns (what you get, for a price) and its
|
||||||
|
-- ROWS are the lines (which baked good, and how much of it in each column). A row therefore holds
|
||||||
|
-- one value per column, in column order — the two are kept in step in Java, because a table whose
|
||||||
|
-- lines and columns disagree quietly misprices what a customer is actually buying.
|
||||||
|
--
|
||||||
|
-- All of it is data rather than markup so the prices can move without a deploy. They will: the last
|
||||||
|
-- line of the spreadsheet says the tables are "mostly just an idea for people".
|
||||||
|
create table catering_package (
|
||||||
|
id bigserial primary key,
|
||||||
|
name varchar(120) not null,
|
||||||
|
-- Optional sentence under the heading. Nothing in the spreadsheet fills this in; it exists so
|
||||||
|
-- the bakery can explain a table without one of us editing a page.
|
||||||
|
blurb varchar(400),
|
||||||
|
position integer not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
updated_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Footnotes belonging to one table: the minimums, the flavor rules, the wedding delivery terms.
|
||||||
|
-- Kept as rows rather than one blob so each rule can be edited, reordered or dropped on its own.
|
||||||
|
create table catering_package_note (
|
||||||
|
package_id bigint not null references catering_package (id) on delete cascade,
|
||||||
|
position integer not null,
|
||||||
|
body varchar(600) not null,
|
||||||
|
primary key (package_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A column: the size, and what it costs. price_cents is nullable for a tier that is priced on
|
||||||
|
-- asking, and is cents rather than a formatted string so the app — not the browser — decides how
|
||||||
|
-- money is written.
|
||||||
|
create table catering_tier (
|
||||||
|
id bigserial primary key,
|
||||||
|
package_id bigint not null references catering_package (id) on delete cascade,
|
||||||
|
label varchar(120) not null,
|
||||||
|
price_cents integer,
|
||||||
|
position integer not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
updated_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create index catering_tier_package_idx on catering_tier (package_id, position);
|
||||||
|
|
||||||
|
-- A line of the table: which baked good.
|
||||||
|
create table catering_row (
|
||||||
|
id bigserial primary key,
|
||||||
|
package_id bigint not null references catering_package (id) on delete cascade,
|
||||||
|
label varchar(200) not null,
|
||||||
|
position integer not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
updated_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
create index catering_row_package_idx on catering_row (package_id, position);
|
||||||
|
|
||||||
|
-- One cell. `position` is the COLUMN — position 0 is the first tier, and so on — so a row always has
|
||||||
|
-- exactly as many values as its package has tiers. Empty strings are meaningful and expected: the
|
||||||
|
-- spreadsheet has lines that are named but not yet quantified.
|
||||||
|
create table catering_row_value (
|
||||||
|
row_id bigint not null references catering_row (id) on delete cascade,
|
||||||
|
position integer not null,
|
||||||
|
value varchar(300) not null,
|
||||||
|
primary key (row_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Footnotes for the page as a whole rather than any one table.
|
||||||
|
create table catering_note (
|
||||||
|
id bigserial primary key,
|
||||||
|
body varchar(600) not null,
|
||||||
|
position integer not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
updated_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seeded from the bakery's own spreadsheet. Wording is theirs; the only changes are expanded
|
||||||
|
-- shorthand ("4 dz cc or sc" -> "4 dz cupcakes or sugar cookies") and fixed typos, because these
|
||||||
|
-- lines are read by customers. Everything here is editable in the admin.
|
||||||
|
insert into catering_package (id, name, position, created_at) values
|
||||||
|
(1, 'Office', 1, now()),
|
||||||
|
(2, 'Parties', 2, now()),
|
||||||
|
(3, 'Weddings', 3, now());
|
||||||
|
|
||||||
|
insert into catering_tier (id, package_id, label, price_cents, position, created_at) values
|
||||||
|
(1, 1, 'Small', 2400, 1, now()),
|
||||||
|
(2, 1, 'Medium', 3200, 2, now()),
|
||||||
|
(3, 1, 'Large', 4000, 3, now()),
|
||||||
|
(4, 2, '15–20 people', 5400, 1, now()),
|
||||||
|
(5, 2, '20–30 people', 7600, 2, now()),
|
||||||
|
(6, 2, '30–40 people', 9800, 3, now()),
|
||||||
|
(7, 3, '125 people', 23600, 1, now()),
|
||||||
|
(8, 3, '200 people', 31000, 2, now()),
|
||||||
|
(9, 3, '250 people', 38600, 3, now());
|
||||||
|
|
||||||
|
insert into catering_row (id, package_id, label, position, created_at) values
|
||||||
|
( 1, 1, 'Mini muffins', 1, now()),
|
||||||
|
( 2, 1, 'Mini scones', 2, now()),
|
||||||
|
( 3, 1, 'Mini cinnamon rolls', 3, now()),
|
||||||
|
( 4, 2, 'Cake', 1, now()),
|
||||||
|
( 5, 2, 'Cupcakes', 2, now()),
|
||||||
|
( 6, 2, 'Sugar cookies', 3, now()),
|
||||||
|
( 7, 3, 'Bride & groom cake (8 in)', 1, now()),
|
||||||
|
( 8, 3, 'Sheet cakes', 2, now()),
|
||||||
|
( 9, 3, '12x17 bars', 3, now()),
|
||||||
|
(10, 3, 'Cupcakes', 4, now()),
|
||||||
|
(11, 3, 'Sugar cookies', 5, now());
|
||||||
|
|
||||||
|
insert into catering_row_value (row_id, position, value) values
|
||||||
|
( 1, 0, '12 items'), ( 1, 1, '18 items'), ( 1, 2, '24 items'),
|
||||||
|
( 2, 0, '6+6'), ( 2, 1, '6+6+6 or 12+6'), ( 2, 2, '6+6+6+6 or 12+6+6 or 12+12'),
|
||||||
|
-- Named in the spreadsheet but never quantified; the minimum below is the only rule it gives.
|
||||||
|
( 3, 0, ''), ( 3, 1, ''), ( 3, 2, ''),
|
||||||
|
( 4, 0, '6 in cake'), ( 4, 1, '8 in cake'), ( 4, 2, '10 in cake'),
|
||||||
|
( 5, 0, '1 dz sugar cookies or cupcakes'), ( 5, 1, '1.5 dz your choice'), ( 5, 2, '2 dz your choice'),
|
||||||
|
( 6, 0, ''), ( 6, 1, ''), ( 6, 2, ''),
|
||||||
|
( 7, 0, 'B&G cake'), ( 7, 1, 'B&G cake'), ( 7, 2, 'B&G cake'),
|
||||||
|
( 8, 0, '2 pans or a sheet cake'), ( 8, 1, '3 pans or a sheet cake'), ( 8, 2, '4 pans or a sheet cake'),
|
||||||
|
( 9, 0, '4 dz cupcakes or sugar cookies'),
|
||||||
|
( 9, 1, '5 dz cupcakes or sugar cookies'),
|
||||||
|
( 9, 2, '6 dz cupcakes or sugar cookies'),
|
||||||
|
(10, 0, ''), (10, 1, ''), (10, 2, ''),
|
||||||
|
(11, 0, ''), (11, 1, ''), (11, 2, '');
|
||||||
|
|
||||||
|
insert into catering_package_note (package_id, position, body) values
|
||||||
|
(1, 0, 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.'),
|
||||||
|
(2, 0, 'Add an extra dozen for $20.'),
|
||||||
|
(2, 1, 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.'),
|
||||||
|
-- Wedding-specific, so it sits with the wedding table rather than under the whole page.
|
||||||
|
(3, 0, 'The delivery fee depends on where the wedding is, and setup is charged separately.'),
|
||||||
|
(3, 1, 'We don''t provide serving materials, and we don''t set up decorations.');
|
||||||
|
|
||||||
|
insert into catering_note (id, body, position, created_at) values
|
||||||
|
(1, 'We''re happy to make changes — the price may change with them.', 1, now()),
|
||||||
|
(2, 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.', 2, now());
|
||||||
|
|
||||||
|
-- bigserial keeps its own counter; move it past the seeded ids so future inserts don't collide.
|
||||||
|
select setval('catering_package_id_seq', 3);
|
||||||
|
select setval('catering_tier_id_seq', 9);
|
||||||
|
select setval('catering_row_id_seq', 11);
|
||||||
|
select setval('catering_note_id_seq', 2);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- The wedding table's lines, read the way the spreadsheet meant them.
|
||||||
|
--
|
||||||
|
-- V4 carried that table over literally, because the source is offset: the prices sit on the "cupcakes"
|
||||||
|
-- line, and "4 dz cc or sc" sits on the "12x17 bars" line. Taken at face value it produced five lines,
|
||||||
|
-- two of which ("Cupcakes", "Sugar cookies") had no quantity in any column and rendered as a row of
|
||||||
|
-- dashes on the page.
|
||||||
|
--
|
||||||
|
-- Reading it as a baker would: a 12x17 pan IS the sheet pan, so "Sheet cakes" and "12x17 bars" are one
|
||||||
|
-- offering, and "cc or sc" is cupcakes-or-sugar-cookies, which is what the 4/5/6 dozen counts belong to.
|
||||||
|
-- That gives three lines that each say something:
|
||||||
|
--
|
||||||
|
-- Bride & groom cake (8 in) B&G cake B&G cake B&G cake
|
||||||
|
-- Sheet cakes or 12x17 bars 2 pans 3 pans 4 pans
|
||||||
|
-- Cupcakes or sugar cookies 4 dz 5 dz 6 dz
|
||||||
|
--
|
||||||
|
-- This is an interpretation of somebody else's shorthand, so every statement below is guarded on the
|
||||||
|
-- text V4 wrote. If the bakery has already edited these lines in the admin, this migration finds nothing
|
||||||
|
-- to change and leaves their wording alone — which matters, because by the time this runs the tables are
|
||||||
|
-- live and theirs to edit.
|
||||||
|
--
|
||||||
|
-- V4 is deliberately NOT edited: it has already been applied, and Flyway validates checksums.
|
||||||
|
|
||||||
|
-- "Sheet cakes" absorbs the pan size, and its cells drop the "or a sheet cake" the label now carries.
|
||||||
|
update catering_row r
|
||||||
|
set label = 'Sheet cakes or 12x17 bars'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id
|
||||||
|
and p.name = 'Weddings'
|
||||||
|
and r.label = 'Sheet cakes';
|
||||||
|
|
||||||
|
update catering_row_value v
|
||||||
|
set value = '2 pans'
|
||||||
|
from catering_row r join catering_package p on p.id = r.package_id
|
||||||
|
where r.id = v.row_id and p.name = 'Weddings'
|
||||||
|
and r.label = 'Sheet cakes or 12x17 bars'
|
||||||
|
and v.position = 0 and v.value = '2 pans or a sheet cake';
|
||||||
|
|
||||||
|
update catering_row_value v
|
||||||
|
set value = '3 pans'
|
||||||
|
from catering_row r join catering_package p on p.id = r.package_id
|
||||||
|
where r.id = v.row_id and p.name = 'Weddings'
|
||||||
|
and r.label = 'Sheet cakes or 12x17 bars'
|
||||||
|
and v.position = 1 and v.value = '3 pans or a sheet cake';
|
||||||
|
|
||||||
|
update catering_row_value v
|
||||||
|
set value = '4 pans'
|
||||||
|
from catering_row r join catering_package p on p.id = r.package_id
|
||||||
|
where r.id = v.row_id and p.name = 'Weddings'
|
||||||
|
and r.label = 'Sheet cakes or 12x17 bars'
|
||||||
|
and v.position = 2 and v.value = '4 pans or a sheet cake';
|
||||||
|
|
||||||
|
-- The dozens were never about the bars; they are the cupcakes-or-sugar-cookies count. The line takes
|
||||||
|
-- that name, and the two empty lines below it are what it replaces.
|
||||||
|
update catering_row r
|
||||||
|
set label = 'Cupcakes or sugar cookies'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id
|
||||||
|
and p.name = 'Weddings'
|
||||||
|
and r.label = '12x17 bars';
|
||||||
|
|
||||||
|
update catering_row_value v
|
||||||
|
set value = '4 dz'
|
||||||
|
from catering_row r join catering_package p on p.id = r.package_id
|
||||||
|
where r.id = v.row_id and p.name = 'Weddings'
|
||||||
|
and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.position = 0 and v.value = '4 dz cupcakes or sugar cookies';
|
||||||
|
|
||||||
|
update catering_row_value v
|
||||||
|
set value = '5 dz'
|
||||||
|
from catering_row r join catering_package p on p.id = r.package_id
|
||||||
|
where r.id = v.row_id and p.name = 'Weddings'
|
||||||
|
and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.position = 1 and v.value = '5 dz cupcakes or sugar cookies';
|
||||||
|
|
||||||
|
update catering_row_value v
|
||||||
|
set value = '6 dz'
|
||||||
|
from catering_row r join catering_package p on p.id = r.package_id
|
||||||
|
where r.id = v.row_id and p.name = 'Weddings'
|
||||||
|
and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.position = 2 and v.value = '6 dz cupcakes or sugar cookies';
|
||||||
|
|
||||||
|
-- Only the wedding table's copies: "Cupcakes" is a real, quantified line in the parties table, which the
|
||||||
|
-- join on the package name protects. Their cells go with them (catering_row_value cascades).
|
||||||
|
delete from catering_row r
|
||||||
|
using catering_package p
|
||||||
|
where p.id = r.package_id
|
||||||
|
and p.name = 'Weddings'
|
||||||
|
and r.label in ('Cupcakes', 'Sugar cookies')
|
||||||
|
and not exists (
|
||||||
|
select 1 from catering_row_value v
|
||||||
|
where v.row_id = r.id and v.value <> ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Positions 1, 2, 3 survive the deletion untouched (the removed lines were 4 and 5), so there is nothing
|
||||||
|
-- to renumber. Stated rather than assumed: the page reads the order from these.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
-- The catering tables in the bakery's voice instead of the spreadsheet's shorthand.
|
||||||
|
--
|
||||||
|
-- V4 kept the source wording deliberately: it was a spreadsheet written for the people who bake from it,
|
||||||
|
-- and inventing copy for somebody else's prices is not a migration's job. But the page is read by
|
||||||
|
-- customers, and "B&G cake", "2 pans", "4 dz" and "6+6" are notes-to-self, not an offer. So this rewrites
|
||||||
|
-- the cells as sentences, fills in the blurb each table always had room for, and merges the parties
|
||||||
|
-- lines the same way V5 merged the wedding ones — "Cupcakes" quantified as "1 dz sugar cookies or
|
||||||
|
-- cupcakes" with an empty "Sugar cookies" line beneath it is the same offset, and the same fix.
|
||||||
|
--
|
||||||
|
-- Guarded on the exact text V4 and V5 left, as V5 was: anything the bakery has already reworded in the
|
||||||
|
-- admin is left exactly as they wrote it. Their wording wins over mine.
|
||||||
|
|
||||||
|
-- --- names and the line under each heading ----------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_package set name = 'Office boxes'
|
||||||
|
where name = 'Office';
|
||||||
|
|
||||||
|
update catering_package set blurb = 'Mini pastries for a morning meeting, boxed and ready to collect.'
|
||||||
|
where name = 'Office boxes' and blurb is null;
|
||||||
|
|
||||||
|
update catering_package set blurb = 'A cake, and something to hand round, sized to your guest list.'
|
||||||
|
where name = 'Parties' and blurb is null;
|
||||||
|
|
||||||
|
update catering_package set blurb = 'A cake for the couple, and dessert for everyone else.'
|
||||||
|
where name = 'Weddings' and blurb is null;
|
||||||
|
|
||||||
|
-- --- office boxes -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'A dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '12 items';
|
||||||
|
update catering_row_value v set value = 'Eighteen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '18 items';
|
||||||
|
update catering_row_value v set value = 'Two dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '24 items';
|
||||||
|
|
||||||
|
-- "6+6" is six of one flavour and six of another, which is worth saying out loud.
|
||||||
|
update catering_row_value v set value = 'A dozen, in two flavors'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6';
|
||||||
|
update catering_row_value v set value = 'Eighteen, in two or three flavors'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6+6 or 12+6';
|
||||||
|
update catering_row_value v set value = 'Two dozen, in up to four flavors'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini scones'
|
||||||
|
and v.value = '6+6+6+6 or 12+6+6 or 12+12';
|
||||||
|
|
||||||
|
update catering_package_note set body = 'Everything is baked in sixes, so each item comes in multiples of six.'
|
||||||
|
where body = 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.';
|
||||||
|
|
||||||
|
insert into catering_package_note (package_id, position, body)
|
||||||
|
select p.id, 1, 'Mixing flavors within one item needs a larger order — ask us and we will tell you.'
|
||||||
|
from catering_package p
|
||||||
|
where p.name = 'Office boxes'
|
||||||
|
and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 1);
|
||||||
|
|
||||||
|
-- --- parties ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'A 6-inch cake'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '6 in cake';
|
||||||
|
update catering_row_value v set value = 'An 8-inch cake'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '8 in cake';
|
||||||
|
update catering_row_value v set value = 'A 10-inch cake'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '10 in cake';
|
||||||
|
|
||||||
|
-- Same offset as the wedding table: the quantities on the "Cupcakes" line were always
|
||||||
|
-- cupcakes-or-sugar-cookies, and the "Sugar cookies" line below carried nothing at all.
|
||||||
|
update catering_row r set label = 'Cupcakes or sugar cookies'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Cupcakes';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'A dozen, either one'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.value = '1 dz sugar cookies or cupcakes';
|
||||||
|
update catering_row_value v set value = 'Eighteen, your choice'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.value = '1.5 dz your choice';
|
||||||
|
update catering_row_value v set value = 'Two dozen, your choice'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.value = '2 dz your choice';
|
||||||
|
|
||||||
|
delete from catering_row r
|
||||||
|
using catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Sugar cookies'
|
||||||
|
and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.value <> '');
|
||||||
|
|
||||||
|
update catering_package_note set body = 'An extra dozen is $20.'
|
||||||
|
where body = 'Add an extra dozen for $20.';
|
||||||
|
update catering_package_note
|
||||||
|
set body = 'Cake and cupcake flavors can be different once you are ordering a dozen cupcakes or more.'
|
||||||
|
where body = 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.';
|
||||||
|
|
||||||
|
-- --- weddings ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- The label said it and the cell repeated it. The cell now carries the size instead.
|
||||||
|
update catering_row r set label = 'Bride & groom cake'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Bride & groom cake (8 in)';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'Eight inch'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Bride & groom cake' and v.value = 'B&G cake';
|
||||||
|
|
||||||
|
update catering_row r set label = 'Sheet cakes or 12×17 bars'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Sheet cakes or 12x17 bars';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'Two pans'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '2 pans';
|
||||||
|
update catering_row_value v set value = 'Three pans'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '3 pans';
|
||||||
|
update catering_row_value v set value = 'Four pans'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '4 pans';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'Four dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '4 dz';
|
||||||
|
update catering_row_value v set value = 'Five dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '5 dz';
|
||||||
|
update catering_row_value v set value = 'Six dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '6 dz';
|
||||||
|
|
||||||
|
-- --- the page's own terms ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_note
|
||||||
|
set body = 'These are a guide rather than a menu. We are happy to make changes, though the price may change with them.'
|
||||||
|
where body = 'We''re happy to make changes — the price may change with them.';
|
||||||
|
|
||||||
|
update catering_note
|
||||||
|
set body = 'If we can''t do something, we will tell you.'
|
||||||
|
where body = 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.';
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- Two things the spreadsheet never said out loud, and the page was getting wrong.
|
||||||
|
--
|
||||||
|
-- 1. AN OFFICE BOX IS A TOTAL, NOT AN ASSORTMENT. A Small box is twelve items, mixed in sixes from the
|
||||||
|
-- minis — not twelve muffins AND twelve scones AND some cinnamon rolls, which is what three ticked
|
||||||
|
-- lines on a card claimed. The bakery confirmed it: mix them, in sixes, to the size's item count.
|
||||||
|
--
|
||||||
|
-- The fix keeps the shape everything else uses rather than inventing a mode for this one table: the
|
||||||
|
-- alternative goes INTO the line, the way "Cupcakes or sugar cookies" already does in the parties and
|
||||||
|
-- wedding tables. One line, "Any mix of…", and the size's cell carries the count. So the rule for the
|
||||||
|
-- whole page is now sayable in one sentence: lines are things you get (and), and a choice within a
|
||||||
|
-- line is written into its name (or).
|
||||||
|
--
|
||||||
|
-- 2. "SMALL" DOESN'T TELL ANYONE HOW MANY PEOPLE IT FEEDS. The party and wedding tables size themselves
|
||||||
|
-- by head count; the office table sizes itself by box. `serves` gives every column somewhere to say
|
||||||
|
-- it, and the admin gets a box for it. The office numbers below are estimates from the item counts and
|
||||||
|
-- are meant to be corrected by the people who pack the boxes.
|
||||||
|
|
||||||
|
alter table catering_tier add column serves varchar(80);
|
||||||
|
|
||||||
|
-- --- 1. the office box's contents ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_row r
|
||||||
|
set label = 'Any mix of mini muffins, mini scones and mini cinnamon rolls'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id
|
||||||
|
and p.name = 'Office boxes'
|
||||||
|
and r.label = 'Mini muffins';
|
||||||
|
|
||||||
|
update catering_row_value v set value = '12 items'
|
||||||
|
from catering_row r
|
||||||
|
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 0 and v.value = 'A dozen';
|
||||||
|
update catering_row_value v set value = '18 items'
|
||||||
|
from catering_row r
|
||||||
|
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 1 and v.value = 'Eighteen';
|
||||||
|
update catering_row_value v set value = '24 items'
|
||||||
|
from catering_row r
|
||||||
|
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 2 and v.value = 'Two dozen';
|
||||||
|
|
||||||
|
-- The other two lines are now named inside the first one. Guarded on the wording V6 left, so a line the
|
||||||
|
-- bakery has since edited stays and can be tidied by hand.
|
||||||
|
delete from catering_row r
|
||||||
|
using catering_package p
|
||||||
|
where p.id = r.package_id
|
||||||
|
and p.name = 'Office boxes'
|
||||||
|
and r.label in ('Mini scones', 'Mini cinnamon rolls');
|
||||||
|
|
||||||
|
-- "6+6" was the spreadsheet showing how a dozen scones splits by flavour. With one mixed line, that
|
||||||
|
-- belongs in the note, where the bakery's own minimum already lives.
|
||||||
|
update catering_package_note
|
||||||
|
set body = 'Baked in sixes, so a dozen can be six muffins and six scones.'
|
||||||
|
where body = 'Everything is baked in sixes, so each item comes in multiples of six.';
|
||||||
|
|
||||||
|
-- --- 2. how many people each column feeds -------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_tier t set serves = 'About 6–8 people'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Small' and t.serves is null;
|
||||||
|
update catering_tier t set serves = 'About 10–12 people'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Medium' and t.serves is null;
|
||||||
|
update catering_tier t set serves = 'About 15–20 people'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Large' and t.serves is null;
|
||||||
|
|
||||||
|
-- The party and wedding columns are already named by head count ("15–20 people", "125 people"), so a
|
||||||
|
-- `serves` line under the price would say it twice. Left null on purpose.
|
||||||
|
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-11 w-11 rounded-full bg-bakery-900/40 ' +
|
||||||
|
'text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 ' +
|
||||||
|
'focus-visible:ring-white';
|
||||||
|
|
||||||
|
function chevron(direction) {
|
||||||
|
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('class', 'h-5 w-5');
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24');
|
||||||
|
svg.setAttribute('fill', 'none');
|
||||||
|
svg.setAttribute('stroke', 'currentColor');
|
||||||
|
svg.setAttribute('stroke-width', '2');
|
||||||
|
svg.setAttribute('stroke-linecap', 'round');
|
||||||
|
svg.setAttribute('stroke-linejoin', 'round');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.setAttribute('d', direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6');
|
||||||
|
svg.appendChild(path);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhance(strip) {
|
||||||
|
var photos = strip.querySelectorAll('img');
|
||||||
|
if (photos.length < 2) return;
|
||||||
|
|
||||||
|
var frame = strip.parentElement;
|
||||||
|
var name = photos[0].getAttribute('alt') || 'this item';
|
||||||
|
|
||||||
|
// Which photo is showing: whichever one's left edge is nearest the strip's scroll position. Read
|
||||||
|
// from the scroll position rather than tracked in a variable, so a swipe and an arrow press can
|
||||||
|
// never disagree about where we are.
|
||||||
|
function current() {
|
||||||
|
return Math.round(strip.scrollLeft / strip.clientWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(index) {
|
||||||
|
var target = Math.max(0, Math.min(photos.length - 1, index));
|
||||||
|
strip.scrollTo({ left: target * strip.clientWidth, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrow(direction, label, position) {
|
||||||
|
var button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = ARROW + ' ' + position;
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.appendChild(chevron(direction));
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
// Wrapping, as the old carousel did: past the last photo you land back on the first.
|
||||||
|
var next = current() + (direction === 'left' ? -1 : 1);
|
||||||
|
if (next < 0) next = photos.length - 1;
|
||||||
|
if (next > photos.length - 1) next = 0;
|
||||||
|
show(next);
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.appendChild(arrow('left', 'Previous photo of ' + name, 'left-2'));
|
||||||
|
frame.appendChild(arrow('right', 'Next photo of ' + name, 'right-2'));
|
||||||
|
|
||||||
|
var dots = document.createElement('div');
|
||||||
|
dots.className = 'absolute inset-x-0 bottom-3 flex justify-center gap-1.5';
|
||||||
|
var buttons = [];
|
||||||
|
photos.forEach(function (_photo, index) {
|
||||||
|
var dot = document.createElement('button');
|
||||||
|
dot.type = 'button';
|
||||||
|
dot.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
'Show photo ' + (index + 1) + ' of ' + photos.length + ' of ' + name,
|
||||||
|
);
|
||||||
|
dot.addEventListener('click', function () {
|
||||||
|
show(index);
|
||||||
|
});
|
||||||
|
buttons.push(dot);
|
||||||
|
dots.appendChild(dot);
|
||||||
|
});
|
||||||
|
frame.appendChild(dots);
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
var active = current();
|
||||||
|
buttons.forEach(function (dot, index) {
|
||||||
|
dot.className =
|
||||||
|
'h-1.5 rounded-full shadow-xs transition-all motion-reduce:transition-none focus:outline-none ' +
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-white ' +
|
||||||
|
(index === active ? 'w-4 bg-white' : 'w-1.5 bg-white/60 hover:bg-white/80');
|
||||||
|
if (index === active) {
|
||||||
|
dot.setAttribute('aria-current', 'true');
|
||||||
|
} else {
|
||||||
|
dot.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard: the strip is focusable, so left/right work once it has focus.
|
||||||
|
strip.tabIndex = 0;
|
||||||
|
strip.setAttribute('role', 'group');
|
||||||
|
strip.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
show(current() - 1);
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
show(current() + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var scheduled = false;
|
||||||
|
strip.addEventListener(
|
||||||
|
'scroll',
|
||||||
|
function () {
|
||||||
|
// A smooth scroll fires this dozens of times; repaint once per frame.
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
scheduled = false;
|
||||||
|
paint();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.gallery').forEach(enhance);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
th:replace="~{admin/layout :: page('The catalogue', ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content">
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
One form per row, not one per button.
|
||||||
|
|
||||||
|
Every control in a row submits that row's form, and `name="do"` says which was pressed — `move:-1`,
|
||||||
|
`photo:<key>:0`, `delete`. Forty items with a form per control came to 467 forms and 464 CSRF tokens,
|
||||||
|
which is 317 KB of HTML for a screen that gets opened on a phone in a bakery; this is 89 forms.
|
||||||
|
|
||||||
|
Only `save` reads the text boxes, so moving a row never saves a half-typed name. Those buttons also
|
||||||
|
carry `formnovalidate`: the name box is `required`, and without it a browser would refuse to submit
|
||||||
|
"move down" while the box was empty — which is nothing to do with moving the row.
|
||||||
|
*/-->
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-heading">Categories</h2>
|
||||||
|
<p class="mt-1 text-sm text-bakery-600">
|
||||||
|
These are the filter buttons on the products page, in this order. Renaming one moves everything
|
||||||
|
filed under it too.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mt-3 divide-y divide-bakery-100">
|
||||||
|
<li th:each="filter, f : ${filters}">
|
||||||
|
<form method="post" th:action="@{/admin/categories/{id}(id=${filter.id})}"
|
||||||
|
class="flex flex-wrap items-center gap-2 py-2">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="submit" name="do" value="move:-1" formnovalidate class="btn-icon" th:disabled="${f.first}"
|
||||||
|
th:aria-label="|Move ${filter.name} up|">↑</button>
|
||||||
|
<button type="submit" name="do" value="move:1" formnovalidate class="btn-icon" th:disabled="${f.last}"
|
||||||
|
th:aria-label="|Move ${filter.name} down|">↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex-1 min-w-52">
|
||||||
|
<span class="sr-only" th:text="|Name of the ${filter.name} category|">Name</span>
|
||||||
|
<input class="field" name="name" th:value="${filter.name}" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" name="do" value="save" class="btn-secondary">Rename</button>
|
||||||
|
|
||||||
|
<span class="text-sm text-bakery-500 whitespace-nowrap"
|
||||||
|
th:text="|${filter.used} item${filter.used == 1 ? '' : 's'}|">0 items</span>
|
||||||
|
|
||||||
|
<button type="submit" name="do" value="delete" formnovalidate class="btn-danger" th:disabled="${filter.used > 0}"
|
||||||
|
th:title="${filter.used > 0} ? 'Move its items somewhere else first' : 'Delete'"
|
||||||
|
th:aria-label="|Delete the ${filter.name} category|">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/categories" class="mt-3 flex gap-2">
|
||||||
|
<label class="flex-1">
|
||||||
|
<span class="sr-only">New category</span>
|
||||||
|
<input class="field" name="name" placeholder="New category" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--/* Adding an item. enctype matters: without it the browser posts filenames instead of files. */-->
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-heading">Add something new</h2>
|
||||||
|
<p class="mt-1 text-sm text-bakery-600">
|
||||||
|
New items go to the top of the products page. Photos are resized, stripped of their EXIF (including
|
||||||
|
the location your phone put in them) and converted on upload, so this can take a few seconds each.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/items" enctype="multipart/form-data" class="mt-3 space-y-3">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[1fr_12rem]">
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">What is it?</span>
|
||||||
|
<input class="field" name="name" placeholder="What is it? e.g. Chocolate drip cake" required>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Category</span>
|
||||||
|
<select class="field" name="category" required>
|
||||||
|
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}">Cakes</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="file" name="photos" accept="image/*" multiple required
|
||||||
|
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
|
||||||
|
<button type="submit" class="btn-primary">Add to the page</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="card-heading" th:text="|On the page (${#lists.size(items)})|">On the page</h2>
|
||||||
|
|
||||||
|
<ul class="mt-3 space-y-3">
|
||||||
|
<li th:each="item, i : ${items}" class="card">
|
||||||
|
<form method="post" th:action="@{/admin/items/{id}(id=${item.id})}"
|
||||||
|
class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||||
|
<div class="flex sm:flex-col gap-1 sm:pt-1">
|
||||||
|
<button type="submit" name="do" value="move:-1" formnovalidate class="btn-icon" th:disabled="${i.first}"
|
||||||
|
th:aria-label="|Move ${item.name} up|">↑</button>
|
||||||
|
<button type="submit" name="do" value="move:1" formnovalidate class="btn-icon" th:disabled="${i.last}"
|
||||||
|
th:aria-label="|Move ${item.name} down|">↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 space-y-3">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[1fr_12rem_auto]">
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Name</span>
|
||||||
|
<input class="field" name="name" th:value="${item.name}" required>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Category</span>
|
||||||
|
<select class="field" name="category">
|
||||||
|
<!--/* An item can sit in a category nobody defined; don't silently retype it. */-->
|
||||||
|
<option th:if="${!#lists.contains(filters.![name], item.category)}"
|
||||||
|
th:value="${item.category}" th:text="${item.category}" selected>Uncategorised</option>
|
||||||
|
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}"
|
||||||
|
th:selected="${filter.name == item.category}">Cakes</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" name="do" value="save" class="btn-secondary">Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* Photos, in the order the products page shows them: the first is the one the card leads
|
||||||
|
with. Left/right rather than a drag target, which is far easier to hit on a phone. */-->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<figure th:each="photo, p : ${item.photos}" class="w-28">
|
||||||
|
<img th:src="${photo.url}" alt="" loading="lazy"
|
||||||
|
class="w-28 h-28 rounded-field object-cover border border-bakery-200 bg-bakery-100">
|
||||||
|
<figcaption class="mt-1 flex items-center justify-between gap-1">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="submit" name="do" th:value="|photo:${photo.key}:-1|" formnovalidate class="btn-icon"
|
||||||
|
th:disabled="${p.first}" aria-label="Move photo earlier">←</button>
|
||||||
|
<button type="submit" name="do" th:value="|photo:${photo.key}:1|" formnovalidate class="btn-icon"
|
||||||
|
th:disabled="${p.last}" aria-label="Move photo later">→</button>
|
||||||
|
</div>
|
||||||
|
<!--/* The server refuses to leave an item with no photos; saying so up front beats an
|
||||||
|
error message. */-->
|
||||||
|
<button type="submit" name="do" th:value="|photo:${photo.key}:0|" formnovalidate class="btn-icon"
|
||||||
|
th:disabled="${#lists.size(item.photos) == 1}"
|
||||||
|
th:title="${#lists.size(item.photos) == 1} ? 'An item needs at least one photo' : 'Remove photo'"
|
||||||
|
aria-label="Remove photo">×</button>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="submit" name="do" value="delete" formnovalidate class="btn-danger ml-auto"
|
||||||
|
th:aria-label="|Remove ${item.name} from the products page|">Delete item</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!--/* Its own form, because a file input needs a multipart encoding and the rest of the row
|
||||||
|
doesn't. */-->
|
||||||
|
<form method="post" th:action="@{/admin/items/{id}/photos(id=${item.id})}"
|
||||||
|
enctype="multipart/form-data" class="mt-2 flex flex-wrap items-center gap-2 sm:pl-12">
|
||||||
|
<input type="file" name="photos" accept="image/*" multiple required
|
||||||
|
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
|
||||||
|
<button type="submit" class="btn-secondary">Add photos</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p th:if="${#lists.isEmpty(items)}" class="mt-3 text-bakery-600">
|
||||||
|
Nothing here yet — add something above.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
th:replace="~{admin/layout :: page('Goodie boxes & catering', ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="card-heading">The price tables</h2>
|
||||||
|
<p class="mt-1 text-sm text-bakery-600">
|
||||||
|
In the order they appear on the page. Open one to change its columns, its prices or what's in it.
|
||||||
|
A table with no columns or no lines stays off the public page until it has both.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mt-3 space-y-3">
|
||||||
|
<li th:each="table, t : ${menu.packages}" class="card flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
|
||||||
|
<input type="hidden" name="by" value="-1">
|
||||||
|
<button type="submit" class="btn-icon" th:disabled="${t.first}"
|
||||||
|
th:aria-label="|Move the ${table.name} table up|">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
|
||||||
|
<input type="hidden" name="by" value="1">
|
||||||
|
<button type="submit" class="btn-icon" th:disabled="${t.last}"
|
||||||
|
th:aria-label="|Move the ${table.name} table down|">↓</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-60">
|
||||||
|
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}"
|
||||||
|
class="link h4 text-bakery-900"
|
||||||
|
th:text="${table.name}">Office</a>
|
||||||
|
<p class="text-sm text-bakery-600">
|
||||||
|
<span th:text="|${#lists.size(table.tiers)} column${#lists.size(table.tiers) == 1 ? '' : 's'}|">3 columns</span>,
|
||||||
|
<span th:text="|${#lists.size(table.rows)} line${#lists.size(table.rows) == 1 ? '' : 's'}|">3 lines</span>
|
||||||
|
<span th:if="${#lists.isEmpty(table.tiers) or #lists.isEmpty(table.rows)}"
|
||||||
|
class="text-bakery-700"> — not on the page yet</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}" class="btn-secondary">Edit</a>
|
||||||
|
|
||||||
|
<form method="post" th:action="@{/admin/catering/tables/{id}/delete(id=${table.id})}">
|
||||||
|
<button type="submit" class="btn-danger"
|
||||||
|
th:aria-label="|Delete the ${table.name} table|">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p th:if="${#lists.isEmpty(menu.packages)}" class="mt-3 text-bakery-600">
|
||||||
|
No tables yet. The catering page will tell people to call instead until there is one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/catering/tables" class="mt-4 flex gap-2">
|
||||||
|
<label class="flex-1">
|
||||||
|
<span class="sr-only">New table</span>
|
||||||
|
<input class="field" name="name" placeholder="New table, e.g. Graduation parties" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--/* The page's own terms, as opposed to the small print under one table. Replaced as a whole list:
|
||||||
|
deleting one is an omission, which is the same rule the tables follow. */-->
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="card-heading">Under the whole page</h2>
|
||||||
|
<p class="mt-1 text-sm text-bakery-600">Terms that apply whichever table someone is reading.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/catering/notes" class="mt-3 space-y-2">
|
||||||
|
<label th:each="note : ${menu.notes}" class="block">
|
||||||
|
<span class="sr-only">Note</span>
|
||||||
|
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" th:text="${note}"></textarea>
|
||||||
|
</label>
|
||||||
|
<!--/* An empty box is how you delete one: blank notes are dropped on save. */-->
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Another note</span>
|
||||||
|
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" placeholder="Add another note"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary">Save these notes</button>
|
||||||
|
<p class="text-sm text-bakery-600">Clearing a box and saving removes that note.</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--/*
|
||||||
|
The admin's shell.
|
||||||
|
|
||||||
|
It deliberately doesn't wear the site's chrome: the public nav would offer an editor links away from
|
||||||
|
what they were doing, and the opening hours in the footer are noise on a screen whose whole job is the
|
||||||
|
catalogue. Same stylesheet, same brand.
|
||||||
|
|
||||||
|
Getting here at all means signing in — /admin/** is an authenticated path, so an unknown visitor is
|
||||||
|
sent to Authentik before any of this renders.
|
||||||
|
*/-->
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="page(title, content)">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<link rel="icon" href="/images/resources/logo_L.png">
|
||||||
|
<title th:text="|${title} · The Vine|">The Vine — admin</title>
|
||||||
|
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="min-h-screen bg-bakery-50">
|
||||||
|
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6">
|
||||||
|
|
||||||
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-lejour text-4xl text-bakery-700">The Vine</h1>
|
||||||
|
<nav class="mt-1 flex flex-wrap gap-4 text-sm">
|
||||||
|
<a href="/admin" class="link">The catalogue</a>
|
||||||
|
<a href="/admin/catering" class="link">Goodie boxes & catering</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/products" class="btn-secondary">View the site</a>
|
||||||
|
<!--/* A real form post: the platform's logout expects one, and it also ends the Authentik
|
||||||
|
session — a link would leave you signed in at the identity provider and straight back in
|
||||||
|
on the next click. */-->
|
||||||
|
<form method="post" action="/logout">
|
||||||
|
<button type="submit" class="btn-secondary">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!--/* One place for the outcome of the last edit, whichever page posted it. */-->
|
||||||
|
<div th:if="${problem}" role="alert"
|
||||||
|
class="mt-6 rounded-field border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
|
||||||
|
th:text="${problem}">Something did not save.</div>
|
||||||
|
<div th:if="${done}" role="status"
|
||||||
|
class="mt-6 rounded-field border border-bakery-200 bg-white px-4 py-3 text-sm text-bakery-800"
|
||||||
|
th:text="${done}">Saved.</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-6">
|
||||||
|
<div th:replace="${content}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
th:replace="~{admin/layout :: page(${table.name} + ' table', ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content">
|
||||||
|
<!--/*
|
||||||
|
One price table, in one form.
|
||||||
|
|
||||||
|
Every button in here submits this form. `name="do"` says which was pressed, and its value carries the
|
||||||
|
position it applies to (`remove-column:2`, `move-line:0:1`) — a button can only send its own name and
|
||||||
|
value, so that is where the argument goes.
|
||||||
|
|
||||||
|
Only "Save" writes anything. The structural buttons come back with the table you were looking at, plus
|
||||||
|
or minus a column or a line, and every cell you had typed still in it: adding a column adds an empty
|
||||||
|
entry to every line, and removing one takes its entries with it, so the grid stays square. That
|
||||||
|
alignment is the invariant CateringPackage#arrange refuses to break, and doing it on the server means
|
||||||
|
there is one implementation of it rather than one here and one in a browser.
|
||||||
|
*/-->
|
||||||
|
<form method="post" th:action="@{/admin/catering/tables/{id}(id=${tableId})}" th:object="${table}">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h2 class="card-heading">This table</h2>
|
||||||
|
<a href="/admin/catering" class="link text-sm">All tables</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid gap-2 sm:grid-cols-[14rem_1fr]">
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Table name</span>
|
||||||
|
<input class="field" th:field="*{name}" placeholder="Weddings" required>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">A line under the heading</span>
|
||||||
|
<input class="field" th:field="*{blurb}" placeholder="Optional — a line under the heading">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* Wide tables scroll here rather than making the page scroll sideways. */-->
|
||||||
|
<div class="mt-4 -mx-4 overflow-x-auto px-4">
|
||||||
|
<table class="w-full border-separate border-spacing-1">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="w-48 text-left text-sm font-medium text-bakery-600">What they get</th>
|
||||||
|
<th th:each="column, c : *{columns}" scope="col" class="min-w-44 align-top">
|
||||||
|
<input type="hidden" th:field="*{columns[__${c.index}__].id}">
|
||||||
|
<input class="field" th:field="*{columns[__${c.index}__].label}" placeholder="Small"
|
||||||
|
th:aria-label="|Heading for column ${c.count}|">
|
||||||
|
<input class="field mt-1" th:field="*{columns[__${c.index}__].price}"
|
||||||
|
placeholder="$24 — leave empty to ask" th:aria-label="|Price for column ${c.count}|">
|
||||||
|
<!--/* Optional: only worth filling in when the heading isn't already a head count. */-->
|
||||||
|
<input class="field mt-1" th:field="*{columns[__${c.index}__].serves}"
|
||||||
|
placeholder="Serves — optional" th:aria-label="|How many people column ${c.count} feeds|">
|
||||||
|
<div class="mt-1 flex justify-center gap-1">
|
||||||
|
<button type="submit" name="do" th:value="|move-column:${c.index}:-1|" class="btn-icon"
|
||||||
|
th:disabled="${c.first}" aria-label="Move this column left">←</button>
|
||||||
|
<button type="submit" name="do" th:value="|move-column:${c.index}:1|" class="btn-icon"
|
||||||
|
th:disabled="${c.last}" aria-label="Move this column right">→</button>
|
||||||
|
<button type="submit" name="do" th:value="|remove-column:${c.index}|" class="btn-icon"
|
||||||
|
title="Removes this column and its entries on every line"
|
||||||
|
aria-label="Remove this column">×</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="w-28 align-top">
|
||||||
|
<button type="submit" name="do" value="add-column" class="btn-secondary">+ Column</button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="line, l : *{lines}">
|
||||||
|
<th scope="row" class="text-left align-top">
|
||||||
|
<input type="hidden" th:field="*{lines[__${l.index}__].id}">
|
||||||
|
<input class="field" th:field="*{lines[__${l.index}__].label}" placeholder="Mini muffins"
|
||||||
|
th:aria-label="|Name of line ${l.count}|">
|
||||||
|
</th>
|
||||||
|
<td th:each="value, v : ${line.values}" class="align-top">
|
||||||
|
<input class="field" th:field="*{lines[__${l.index}__].values[__${v.index}__]}" placeholder="—"
|
||||||
|
th:aria-label="|Line ${l.count}, column ${v.count}|">
|
||||||
|
</td>
|
||||||
|
<td class="align-top">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="submit" name="do" th:value="|move-line:${l.index}:-1|" class="btn-icon"
|
||||||
|
th:disabled="${l.first}" aria-label="Move this line up">↑</button>
|
||||||
|
<button type="submit" name="do" th:value="|move-line:${l.index}:1|" class="btn-icon"
|
||||||
|
th:disabled="${l.last}" aria-label="Move this line down">↓</button>
|
||||||
|
<button type="submit" name="do" th:value="|remove-line:${l.index}|" class="btn-icon"
|
||||||
|
aria-label="Remove this line">×</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" name="do" value="add-line" class="btn-secondary mt-1">+ Line</button>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="text-sm text-bakery-600">
|
||||||
|
Small print under this table — minimums, what can't be mixed, how delivery is charged.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<div th:each="note, n : *{notes}" class="flex items-start gap-2">
|
||||||
|
<label class="flex-1">
|
||||||
|
<span class="sr-only" th:text="|Note ${n.count}|">Note</span>
|
||||||
|
<textarea class="field min-h-[3.25rem]" rows="2" th:field="*{notes[__${n.index}__]}"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit" name="do" th:value="|remove-note:${n.index}|" class="btn-icon mt-1"
|
||||||
|
aria-label="Remove this note">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="do" value="add-note" class="btn-secondary mt-2">+ Note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-bakery-100 pt-3">
|
||||||
|
<button type="submit" name="do" value="save" class="btn-primary">Save this table</button>
|
||||||
|
<a th:href="@{/admin/catering/tables/{id}(id=${tableId})}" class="btn-secondary">Start again</a>
|
||||||
|
<span th:if="${unsaved}" class="text-sm text-bakery-700">
|
||||||
|
Not saved yet — press Save when the table looks right.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content">
|
||||||
|
|
||||||
|
<div th:replace="~{fragments/opening :: photographed(
|
||||||
|
'Order ahead',
|
||||||
|
'Goodie boxes & catering',
|
||||||
|
'Boxes for the office, packages for a party, and cakes and desserts for a wedding.',
|
||||||
|
'gallery/Cakes.webp')}"></div>
|
||||||
|
|
||||||
|
<div class="bg-bakery-50">
|
||||||
|
<div class="container pt-14 pb-16 md:pt-16 md:pb-24">
|
||||||
|
|
||||||
|
<p class="measure text-center text-bakery-800 leading-relaxed">
|
||||||
|
Every one of these is a starting point. Tell us the date, the number of people and what you had in
|
||||||
|
mind, and we will bake to it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-10" th:replace="~{fragments/opening :: divider}"></div>
|
||||||
|
|
||||||
|
<div class="mt-12 max-w-6xl mx-auto space-y-16 md:space-y-20">
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
One block per table, and each size within it is a card: the price, then what you get for it.
|
||||||
|
|
||||||
|
This replaced a real <table>. A table is the honest shape for a price matrix, and it is how the
|
||||||
|
bakery keeps these — but it is also exactly what the source spreadsheet looks like, and reading a
|
||||||
|
spreadsheet back to a customer is not an offer. Cards say "here is what you get" three times over
|
||||||
|
instead of asking someone to find the cell where a row meets a column, and they need no separate
|
||||||
|
phone rendering, because a stack of cards IS the phone rendering.
|
||||||
|
|
||||||
|
Each card walks the same rows in the same order and takes the cell for its own column — the
|
||||||
|
alignment the aggregate guarantees.
|
||||||
|
*/-->
|
||||||
|
<section th:each="table : ${menu.packages}">
|
||||||
|
<div th:replace="~{fragments/opening :: section-head(${table.name}, ${table.blurb})}"></div>
|
||||||
|
|
||||||
|
<!--/* Three sizes is the common case; a table with one or two shouldn't stretch to fill the row. */-->
|
||||||
|
<!--/*
|
||||||
|
Three sizes go three-up from md, not lg like the shared `card-grid`: a grid of PHOTOS is fine
|
||||||
|
two-wide, but three prices are a comparison, and landing 2 + 1 on a laptop strands the third
|
||||||
|
size on its own row where it reads as an afterthought.
|
||||||
|
|
||||||
|
The column count is entirely in the classappend rather than overriding `card-grid`, and that
|
||||||
|
is not a style preference: Tailwind emits grid-cols-1, -2, -3 in that order, so `grid-cols-3`
|
||||||
|
from the shared class would beat a `grid-cols-2` written next to it in the markup. Two classes
|
||||||
|
for one property, with the winner decided by the stylesheet's order — the same trap that made
|
||||||
|
`hidden` fail to hide the open-now line. Never both.
|
||||||
|
*/-->
|
||||||
|
<div class="grid gap-6 md:gap-8 mt-8"
|
||||||
|
th:classappend="${#lists.size(table.tiers) == 1} ? 'max-w-sm mx-auto'
|
||||||
|
: (${#lists.size(table.tiers) == 2} ? 'sm:grid-cols-2 max-w-3xl mx-auto'
|
||||||
|
: 'sm:grid-cols-2 md:grid-cols-3')">
|
||||||
|
<article th:each="tier, t : ${table.tiers}"
|
||||||
|
class="relative flex flex-col overflow-hidden panel">
|
||||||
|
<!--/* The wordmark's branch, at watermark weight. */-->
|
||||||
|
<span class="panel-mark -top-3 -right-4 w-24 h-24 rotate-12" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<div class="panel-head relative px-6 py-6 md:px-8">
|
||||||
|
<p class="label text-bakery-600" th:text="${tier.label}">Small</p>
|
||||||
|
<p th:if="${tier.price}" class="price mt-2 text-bakery-900" th:text="${tier.price}">$24</p>
|
||||||
|
<!--/* No price means "ask us" — say so rather than leaving a hole in the card. */-->
|
||||||
|
<p th:unless="${tier.price}" class="price mt-2 text-2xl text-bakery-700">Ask us</p>
|
||||||
|
<!--/* "Small" doesn't tell anyone how many it feeds. A column whose heading is already a
|
||||||
|
head count leaves this blank rather than saying it twice. */-->
|
||||||
|
<p th:if="${tier.serves}" class="mt-1 text-sm text-bakery-600" th:text="${tier.serves}">About 6–8 people</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="px-6 py-6 md:px-8 space-y-3 text-bakery-800">
|
||||||
|
<li th:each="row : ${table.rows}" class="flex gap-3">
|
||||||
|
<!--/* A tick, not a bullet: this is a list of what you get, not prose. */-->
|
||||||
|
<svg class="mt-1 h-4 w-4 shrink-0 text-bakery-500" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true">
|
||||||
|
<path d="M20 6L9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<span th:text="${row.label}">Mini muffins</span>
|
||||||
|
<!--/* A line the bakery hasn't quantified for this size still belongs in the box; it
|
||||||
|
simply appears without a number, rather than as an empty cell in a grid. */-->
|
||||||
|
<span th:if="${!#strings.isEmpty(row.values[t.index])}" class="text-bakery-600"
|
||||||
|
th:text="|— ${row.values[t.index]}|">— a dozen</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
The small print, then one link for the whole table.
|
||||||
|
|
||||||
|
A button on every card was the obvious pricing-page move and it was wrong twice over: all
|
||||||
|
three cards led to the same place, since an enquiry is about the table rather than the size,
|
||||||
|
and the label read "Ask about the 15–20 people" on the tables whose sizes are headcounts.
|
||||||
|
*/-->
|
||||||
|
<div class="panel-quiet mt-8 measure px-6 py-6 text-center">
|
||||||
|
<div th:if="${!#lists.isEmpty(table.notes)}">
|
||||||
|
<h3 class="label text-bakery-500">Good to know</h3>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm text-bakery-700">
|
||||||
|
<li th:each="note : ${table.notes}" th:text="${note}">Everything is baked in sixes.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
|
||||||
|
class="pill-outline mt-6 no-print"
|
||||||
|
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office boxes</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--/* Only shows if every table has been emptied out in the admin. Better than a bare heading. */-->
|
||||||
|
<p th:if="${#lists.isEmpty(menu.packages)}" class="text-center text-bakery-800">
|
||||||
|
Our catering list is being updated. Call us on
|
||||||
|
<a href="tel:+13097010660" class="link-plain">(309) 701-0660</a> and we will
|
||||||
|
talk it through.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* The terms that apply whichever table you were reading, and the one real call to action. On sage,
|
||||||
|
like the homepage's story band, so the page ends rather than trailing off. */-->
|
||||||
|
<section class="section bg-bakery-800 text-white no-print">
|
||||||
|
<div class="container">
|
||||||
|
<div class="measure text-center">
|
||||||
|
<h2 class="h3">Before you order</h2>
|
||||||
|
<ul th:if="${!#lists.isEmpty(menu.notes)}" class="mt-6 space-y-3 text-bakery-100 leading-relaxed">
|
||||||
|
<li th:each="note : ${menu.notes}" th:text="${note}">These are a guide rather than a menu.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-10 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<a href="/contact"
|
||||||
|
class="pill-cream">
|
||||||
|
Tell us what you need
|
||||||
|
</a>
|
||||||
|
<a href="tel:+13097010660"
|
||||||
|
class="pill-ghost">
|
||||||
|
(309) 701-0660
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||||
|
|
||||||
|
<div th:replace="~{fragments/opening :: plain(
|
||||||
|
'Say hello',
|
||||||
|
'Contact us',
|
||||||
|
'Ask about a cake, a date, or an order for a crowd. We read everything that comes in.')}"></div>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
The form, and beside it the things somebody on this page is usually actually after: when we are open,
|
||||||
|
where we are, and the phone number. They were on the homepage and in the footer but not here, which
|
||||||
|
made this page a form and nothing else.
|
||||||
|
*/-->
|
||||||
|
<div class="container pb-16 md:pb-24">
|
||||||
|
<div class="grid gap-8 lg:grid-cols-[1.4fr_1fr] measure-wide items-start">
|
||||||
|
<div class="panel p-8 md:p-10">
|
||||||
|
<h2 class="h3 text-bakery-900 mb-2">Get in touch</h2>
|
||||||
|
<p class="text-bakery-700 mb-8">
|
||||||
|
Or call us: <a href="tel:+13097010660" class="link-plain">(309) 701-0660</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
A plain form post. The enquiry is validated, written down and mailed by the server, which then
|
||||||
|
renders this page again with the outcome — no fetch, no JSON, and nothing to go wrong between
|
||||||
|
pressing the button and the enquiry being recorded.
|
||||||
|
|
||||||
|
Spring Security's CSRF token is added to any th:action form automatically, which is what keeps
|
||||||
|
this working on a deployment that has an identity provider configured (there, an unaccompanied
|
||||||
|
POST is rejected).
|
||||||
|
*/-->
|
||||||
|
<!--/* th:unless, not th:if="${!sent}": the attribute is absent on a GET, and SpEL cannot negate
|
||||||
|
a null. */-->
|
||||||
|
<form th:action="@{/contact}" method="post" th:unless="${sent}">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-bakery-800 mb-2" for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" th:value="${name}" required
|
||||||
|
class="field">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-bakery-800 mb-2" for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" th:value="${email}" required
|
||||||
|
class="field">
|
||||||
|
</div>
|
||||||
|
<!--/*
|
||||||
|
The spam trap. Not display:none — the crawlers that matter check for that — but positioned off
|
||||||
|
the page, skipped by the tab key and told not to autofill. A person never sees it; a bot fills in
|
||||||
|
every field it finds. See SiteController#submit for what happens then.
|
||||||
|
*/-->
|
||||||
|
<div class="absolute left-[-9999px]" aria-hidden="true">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-bakery-800 mb-2" for="message">Message</label>
|
||||||
|
<textarea id="message" name="message" rows="5" required th:text="${message}"
|
||||||
|
class="field"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<button type="submit"
|
||||||
|
class="pill-sage">
|
||||||
|
Send message
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!--/* Never claim a success we did not get: say what happened and give them the phone number. */-->
|
||||||
|
<p th:if="${error}" role="alert" class="text-center text-red-700">
|
||||||
|
<span th:text="${error}">Could not send the message.</span>
|
||||||
|
Please call us on
|
||||||
|
<a href="tel:+13097010660" class="link-plain">(309) 701-0660</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p th:if="${sent}" role="status" class="text-bakery-700">
|
||||||
|
Thanks. Your message is on its way, and we will get back to you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="space-y-6">
|
||||||
|
<div class="panel p-6 md:p-8">
|
||||||
|
<h2 class="h4 text-bakery-900">Our hours</h2>
|
||||||
|
<div class="mt-4 text-bakery-800"
|
||||||
|
th:replace="~{fragments/visit :: hours('text-bakery-800')}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel p-6 md:p-8">
|
||||||
|
<h2 class="h4 text-bakery-900">Find us</h2>
|
||||||
|
<div class="mt-4 text-bakery-800"
|
||||||
|
th:replace="~{fragments/visit :: address('link-plain')}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-quiet p-6 md:p-8">
|
||||||
|
<h2 class="h4 text-bakery-900">Ordering for a crowd?</h2>
|
||||||
|
<p class="mt-2 text-sm text-bakery-700 leading-relaxed">
|
||||||
|
Goodie boxes, party packages and wedding desserts have their own page, with sizes and prices.
|
||||||
|
</p>
|
||||||
|
<a href="/catering" class="link mt-4 inline-block font-medium">
|
||||||
|
Goodie boxes & catering
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</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 py-24 md:py-32 text-center">
|
||||||
|
<h1 class="h1 text-bakery-900 mb-6">Something went wrong</h1>
|
||||||
|
<p class="text-bakery-800 mb-10">
|
||||||
|
That is our fault, not yours. Try again in a moment, or call us on
|
||||||
|
<a href="tel:+13097010660" class="link-plain">(309) 701-0660</a>.
|
||||||
|
</p>
|
||||||
|
<a href="/"
|
||||||
|
class="pill-sage">
|
||||||
|
Back home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--/*
|
||||||
|
The site's own 404, in the site's own chrome.
|
||||||
|
|
||||||
|
Boot picks this up by name for any 404, which is why the SPA fallback is switched off in
|
||||||
|
application.yaml: while it was on, every mistyped URL was answered with the JavaScript shell and a
|
||||||
|
200 — and now that the shell only carries the admin, that would be a blank page.
|
||||||
|
|
||||||
|
Boot renders it through its own error controller, so SiteController's model isn't here — which is why
|
||||||
|
the layout takes the title and description as parameters and this page passes its own.
|
||||||
|
*/-->
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
th:replace="~{fragments/page :: page(
|
||||||
|
'Page not found · The Vine Coffeehouse + Bakery',
|
||||||
|
'That page is not here. The menu, our catering list, our story and how to reach us are.',
|
||||||
|
'/', ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content" class="bg-bakery-50">
|
||||||
|
<div class="container py-24 md:py-32 text-center">
|
||||||
|
<h1 class="h1 text-bakery-900 mb-6">
|
||||||
|
We could not find that page
|
||||||
|
</h1>
|
||||||
|
<p class="text-bakery-800 mb-10">
|
||||||
|
It may have moved. The menu, our story, and how to reach us are all still here.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<a href="/"
|
||||||
|
class="pill-sage">
|
||||||
|
Back home
|
||||||
|
</a>
|
||||||
|
<a href="/products"
|
||||||
|
class="pill-outline">
|
||||||
|
See the menu
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<body>
|
||||||
|
<footer th:fragment="footer" class="bg-bakery-900 text-bakery-100">
|
||||||
|
<div class="container py-14">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||||
|
<div class="col-span-1 md:col-span-2 flex items-start">
|
||||||
|
<div th:replace="~{fragments/lockup :: small('text-bakery-50')}"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="label text-bakery-300 mb-4 font-adbhashitha">Navigation</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li><a href="/products" class="hover:text-white transition-colors">Our Products</a></li>
|
||||||
|
<li><a href="/catering" class="hover:text-white transition-colors">Goodie Boxes & Catering</a></li>
|
||||||
|
<li><a href="/history" class="hover:text-white transition-colors">Our Story</a></li>
|
||||||
|
<li><a href="/contact" class="hover:text-white transition-colors">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="label text-bakery-300 mb-4 font-adbhashitha">Visit</h3>
|
||||||
|
<div th:replace="~{fragments/visit :: address('link-on-dark')}"></div>
|
||||||
|
<!--/* The hours are the thing people come to a bakery's site for, and the footer is on every
|
||||||
|
page. */-->
|
||||||
|
<div class="mt-4 text-sm text-bakery-200"
|
||||||
|
th:replace="~{fragments/visit :: hours('text-sm text-bakery-200')}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 text-center text-sm text-bakery-300">
|
||||||
|
<p>© <span th:text="${year}">2026</span> The Vine Coffeehouse + Bakery</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<!--/*
|
||||||
|
The page head. `title`, `description`, `path` and (on the homepage) `preload` come from the model —
|
||||||
|
see SiteController, where each route states its own.
|
||||||
|
|
||||||
|
This is what the SPA could not do. PageMetaController used to rewrite the shell's <title> and four
|
||||||
|
meta tags with regular expressions on the way out, and its test read the real index.html so that
|
||||||
|
reformatting that file failed the build rather than silently breaking the rewriting. A page that is
|
||||||
|
rendered on the server just writes its own head, so all of that is gone.
|
||||||
|
*/-->
|
||||||
|
<head th:fragment="head(title, description, path)">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png">
|
||||||
|
<link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png">
|
||||||
|
|
||||||
|
<!--/* Open the connection to the photo bucket while the head is still parsing. */-->
|
||||||
|
<link rel="preconnect" th:href="${assetOrigin}" crossorigin>
|
||||||
|
<!--/* All three are above the fold — the wordmark, every heading, and the body — and all three together
|
||||||
|
are now smaller than the display face alone used to be. Without preloading, each page opens in
|
||||||
|
the fallback and then reflows into the brand. */-->
|
||||||
|
<link rel="preload" as="font" type="font/woff2" href="/fonts/raleway-latin.woff2" crossorigin>
|
||||||
|
<link rel="preload" as="font" type="font/woff2" href="/fonts/LeJour-Script.woff2" crossorigin>
|
||||||
|
<link rel="preload" as="font" type="font/woff2" href="/fonts/AdBhashitha.woff2" crossorigin>
|
||||||
|
<link th:if="${preload}" rel="preload" as="image" th:href="${preload}" fetchpriority="high">
|
||||||
|
|
||||||
|
<title th:text="${title}">The Vine Coffeehouse + Bakery</title>
|
||||||
|
<meta name="description" th:content="${description}">
|
||||||
|
<meta property="og:title" th:content="${title}">
|
||||||
|
<meta property="og:description" th:content="${description}">
|
||||||
|
<meta property="og:url" th:content="${baseUrl + (path == '/' ? '' : path)}">
|
||||||
|
<!--/* Without an image a shared link is a grey box with a title. This is the storefront. */-->
|
||||||
|
<meta property="og:image" th:content="${shareImage}">
|
||||||
|
<meta property="og:image:alt" content="The Vine Coffeehouse + Bakery on Main Street, Princeville">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:locale" content="en_US">
|
||||||
|
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
|
||||||
|
|
||||||
|
<!--/* ?v= is the commit sha in a deployment. One hand-written stylesheet has no content hash in its
|
||||||
|
name, so without it a returning visitor keeps the CSS they cached before the deploy. */-->
|
||||||
|
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
|
||||||
|
|
||||||
|
<!--/* The shop as a search engine reads it: a Bakery, its address, its phone number and its opening
|
||||||
|
times, from the same record the page prints. It is how a local shop gets "Open ⋅ closes 2pm"
|
||||||
|
next to its listing. Unescaped because it is JSON, written by Jackson — see StructuredData. */-->
|
||||||
|
<script type="application/ld+json" th:utext="${structuredData}"></script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<body>
|
||||||
|
<!--/*
|
||||||
|
The sticky bar, and the mobile menu.
|
||||||
|
|
||||||
|
The menu is a <details> element. That is not a trick to avoid writing JavaScript for its own sake —
|
||||||
|
the React version needed an effect to close the panel on navigation, another to close it on Escape,
|
||||||
|
another to stop the page behind it scrolling, and a third to unmount it (a panel parked off-screen
|
||||||
|
still extends the scrollable area, which is how you used to be able to scroll sideways and find the
|
||||||
|
menu). A <details> closes on Escape by itself, and navigation is a new document, so it cannot survive
|
||||||
|
it. Nothing to remember, nothing to clean up.
|
||||||
|
|
||||||
|
The panel is `absolute` and starts below the bar (`top-full`), so the logo and the toggle stay put and
|
||||||
|
the panel needs no second copy of either. It must not be `fixed`: the header carries `backdrop-blur`,
|
||||||
|
and a backdrop-filter makes an element a containing block for fixed descendants — a fixed panel in
|
||||||
|
here would resolve against the 80px header box and get clipped to a sliver.
|
||||||
|
*/-->
|
||||||
|
<header th:fragment="header" class="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200">
|
||||||
|
<div class="container">
|
||||||
|
<div class="flex items-center justify-between gap-2 h-20 md:h-24">
|
||||||
|
<div th:replace="~{fragments/lockup :: small('text-bakery-700')}"></div>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
The status line is the first thing to go when the bar gets tight: at the width of a small laptop
|
||||||
|
the wordmark and the nav were overlapping, because "Closed · opens Tuesday at 7:00am" is as wide as
|
||||||
|
two nav items. It comes back at lg, and the mobile menu carries it at every width below md.
|
||||||
|
*/-->
|
||||||
|
<div class="hidden md:flex items-center gap-6 xl:gap-8">
|
||||||
|
<nav class="flex items-center gap-6 xl:gap-8">
|
||||||
|
<th:block th:replace="~{:: links('label text-bakery-700 hover:text-bakery-900 transition-colors')}"></th:block>
|
||||||
|
</nav>
|
||||||
|
<!--/* Worked out on the server from the same opening times the footer prints. */-->
|
||||||
|
<p th:replace="~{fragments/visit :: open-now(
|
||||||
|
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
|
||||||
|
'hidden xl:inline-flex text-sm text-bakery-600 whitespace-nowrap')}"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="group md:hidden shrink-0">
|
||||||
|
<summary class="p-2 cursor-pointer list-none" aria-label="Menu">
|
||||||
|
<svg class="h-6 w-6 text-bakery-700 group-open:hidden" fill="none" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
<svg class="h-6 w-6 text-bakery-700 hidden group-open:block" fill="none" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div class="absolute inset-x-0 top-full z-30 h-[calc(100dvh-5rem)] bg-bakery-50">
|
||||||
|
<nav class="container flex flex-col">
|
||||||
|
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition-colors py-4 text-lg border-b border-bakery-100')}"></th:block>
|
||||||
|
<p th:replace="~{fragments/visit :: open-now(
|
||||||
|
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
|
||||||
|
'inline-flex text-sm text-bakery-600 py-4')}"></p>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
One list of links, worn two ways — so the phone and the desktop can't end up offering different pages.
|
||||||
|
|
||||||
|
The page you are on is marked: aria-current for a screen reader, and a sage rule under the word for
|
||||||
|
everyone else. A four-page site can get away without it, right up until someone clicks Catering, lands
|
||||||
|
on a page of prices, and has no idea whether the click worked.
|
||||||
|
*/-->
|
||||||
|
<th:block th:fragment="links(itemClass)">
|
||||||
|
<a th:each="item : ${ {{'/products','Our Products'},{'/catering','Catering'},{'/history','Our Story'},{'/contact','Contact'}} }"
|
||||||
|
th:href="${item[0]}" th:text="${item[1]}"
|
||||||
|
th:aria-current="${path == item[0] ? 'page' : null}"
|
||||||
|
th:class="${itemClass} + (${path == item[0]} ? ' is-here' : '')">Our Products</a>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<body>
|
||||||
|
<!--/*
|
||||||
|
The wordmark: branch · "The Vine" over "Coffeehouse + Bakery" · branch.
|
||||||
|
|
||||||
|
The two branch marks are masks over currentColor (see .mark in components.css), so the whole lockup takes
|
||||||
|
its colour from the `tint` class the caller passes — sage in the header, cream on the hero and in the
|
||||||
|
footer. Branch heights track the two-line wordmark so the marks read as part of it.
|
||||||
|
|
||||||
|
Every size here steps at a breakpoint, because the lockup is three things that have to stay in
|
||||||
|
proportion: two fixed-width marks and a tagline that cannot wrap. Give a phone the desktop sizes and
|
||||||
|
they add up to more than its width, and the only part that CAN give — the name — wraps to two lines with
|
||||||
|
the marks centred against it. Which is what it did.
|
||||||
|
*/-->
|
||||||
|
|
||||||
|
<th:block th:fragment="inner(branch, name, tag)">
|
||||||
|
<span th:class="'mark mark-r shrink-0 ' + ${branch}" aria-hidden="true"></span>
|
||||||
|
<span class="flex flex-col items-center leading-none min-w-0">
|
||||||
|
<span th:class="'font-lejour whitespace-nowrap ' + ${name}" style="letter-spacing: 0.01em">The Vine</span>
|
||||||
|
<span th:class="'font-adbhashitha uppercase mt-1.5 whitespace-nowrap ' + ${tag}">Coffeehouse + Bakery</span>
|
||||||
|
</span>
|
||||||
|
<span th:class="'mark mark-l shrink-0 ' + ${branch}" aria-hidden="true"></span>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!--/* Header and footer: a link home. */-->
|
||||||
|
<a th:fragment="small(tint)" href="/"
|
||||||
|
th:class="'flex items-center gap-1.5 sm:gap-2 min-w-0 shrink ' + ${tint}"
|
||||||
|
aria-label="The Vine Coffeehouse + Bakery, home">
|
||||||
|
<th:block th:replace="~{:: inner(
|
||||||
|
'w-11 h-11 sm:w-14 sm:h-14',
|
||||||
|
'text-xl sm:text-2xl xl:text-3xl',
|
||||||
|
'text-[0.55rem] sm:text-xs tracking-[0.12em] sm:tracking-[0.18em]')}"></th:block>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!--/* The hero, on the homepage — where a link back to "/" would be pointless. */-->
|
||||||
|
<div th:fragment="large(tint)"
|
||||||
|
th:class="'flex items-center justify-center gap-2 sm:gap-4 min-w-0 shrink ' + ${tint}"
|
||||||
|
role="img" aria-label="The Vine Coffeehouse + Bakery">
|
||||||
|
<th:block th:replace="~{:: inner(
|
||||||
|
'w-14 h-14 sm:w-20 sm:h-20 md:w-28 md:h-28',
|
||||||
|
'text-3xl sm:text-5xl md:text-6xl',
|
||||||
|
'text-[0.6rem] sm:text-base tracking-[0.15em] sm:tracking-[0.2em]')}"></th:block>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<body>
|
||||||
|
<!--/*
|
||||||
|
How a page begins. Every page, the same three things in the same order: a small-caps line, the title in
|
||||||
|
the display face, and one sentence saying what this is.
|
||||||
|
|
||||||
|
Two dresses for it. `photographed` is for the pages that are selling or telling — catering and the story
|
||||||
|
— where a blurred photo under the sage wash does the work the homepage hero does. `plain` is for the
|
||||||
|
pages you arrive at already knowing what you want, where a photograph would be in the way of the thing
|
||||||
|
you came for: the catalogue, the contact form.
|
||||||
|
|
||||||
|
Before this the four openings were four different pieces of markup that happened to look similar.
|
||||||
|
*/-->
|
||||||
|
|
||||||
|
<section th:fragment="photographed(eyebrow, title, lede, image)"
|
||||||
|
class="band relative flex items-center bg-bakery-900 text-white overflow-hidden">
|
||||||
|
<img th:src="${photos.of(image)}" alt=""
|
||||||
|
class="absolute inset-0 w-full h-full object-cover blur-[3px] scale-110">
|
||||||
|
<!--/* Sage wash rather than a neutral black scrim — the tint is the identity. */-->
|
||||||
|
<div class="absolute inset-0 bg-bakery-900/80"></div>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
|
||||||
|
|
||||||
|
<div class="relative container w-full min-w-0">
|
||||||
|
<div class="measure text-center">
|
||||||
|
<p class="eyebrow text-bakery-200" th:text="${eyebrow}">Order ahead</p>
|
||||||
|
<h1 class="h1 mt-3 text-bakery-50" th:text="${title}">Goodie boxes & catering</h1>
|
||||||
|
<p class="lede mt-5 text-bakery-100" th:text="${lede}">One line about the page.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<header th:fragment="plain(eyebrow, title, lede)" class="page-head">
|
||||||
|
<p class="eyebrow text-bakery-500" th:text="${eyebrow}">From the counter</p>
|
||||||
|
<h1 class="h1 mt-3 text-bakery-900" th:text="${title}">Our products</h1>
|
||||||
|
<p class="lede mt-4 measure text-bakery-800" th:text="${lede}">One line about the page.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
How a section begins, as opposed to a page: the title, centred, and optionally a line under it. Pass an
|
||||||
|
empty string for no line.
|
||||||
|
|
||||||
|
It was five different pieces of markup — centred with one margin, centred with another, left-aligned
|
||||||
|
with a blurb — for what is one idea repeated down every page.
|
||||||
|
*/-->
|
||||||
|
<div th:fragment="section-head(title, blurb)" class="measure text-center">
|
||||||
|
<h2 class="h2 text-bakery-900" th:text="${title}">What people come in for</h2>
|
||||||
|
<p th:if="${!#strings.isEmpty(blurb)}" class="mt-3 text-bakery-700 leading-relaxed" th:text="${blurb}">A line.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* The same, on the sage bands, where the type is cream. */-->
|
||||||
|
<div th:fragment="section-head-dark(title, blurb)" class="measure text-center">
|
||||||
|
<h2 class="h2" th:text="${title}">Before you order</h2>
|
||||||
|
<p th:if="${!#strings.isEmpty(blurb)}" class="mt-3 text-bakery-100 leading-relaxed" th:text="${blurb}">A line.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* The branch from the wordmark, between one part of a page and the next. */-->
|
||||||
|
<div th:fragment="divider" class="flex justify-center" aria-hidden="true">
|
||||||
|
<span class="mark mark-r w-12 h-12 text-bakery-400"></span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--/*
|
||||||
|
The shell every page wears: the head, the sticky header, the footer, and the flex column that keeps
|
||||||
|
the footer at the bottom of a short page.
|
||||||
|
|
||||||
|
A page replaces its own <html> with this fragment and passes its content in as `~{::content}` — a
|
||||||
|
reference to the fragment named "content" in the page's own file. That is how a layout works in
|
||||||
|
Thymeleaf without a layout dialect, and it means the chrome exists once: a page that wanted its own
|
||||||
|
header would have to say so.
|
||||||
|
|
||||||
|
Title, description and path are parameters rather than model lookups so that the error pages — which
|
||||||
|
Boot renders through its own controller, with none of SiteController's model — can pass their own.
|
||||||
|
*/-->
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="page(title, description, path, content)">
|
||||||
|
<head th:replace="~{fragments/head :: head(${title}, ${description}, ${path})}"></head>
|
||||||
|
<body>
|
||||||
|
<div class="min-h-screen bg-bakery-50 flex flex-col">
|
||||||
|
<!--/* Hidden until it has focus: the first thing a keyboard reaches, so the nav isn't a toll gate on
|
||||||
|
every page. */-->
|
||||||
|
<a href="#content" class="skip-link">Skip to the page</a>
|
||||||
|
<div th:replace="~{fragments/header :: header}"></div>
|
||||||
|
<main id="content" class="flex-grow">
|
||||||
|
<div th:replace="${content}"></div>
|
||||||
|
</main>
|
||||||
|
<div th:replace="~{fragments/footer :: footer}"></div>
|
||||||
|
</div>
|
||||||
|
<!--/* The only script on the public site: it gives the multi-photo product cards their arrows and
|
||||||
|
dots. Without it they are still a swipeable strip of photos. */-->
|
||||||
|
<script defer th:src="|/js/gallery.js?v=${build}|"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<body>
|
||||||
|
<!--/*
|
||||||
|
The opening times and the address, printed from the shop's own record rather than from markup.
|
||||||
|
|
||||||
|
They were three hand-written lines repeated in three templates. Now `Hours` collapses the week into the
|
||||||
|
lines a sign would show — consecutive days that keep the same times become one row — and the same data
|
||||||
|
answers "are they open right now?" in the header and describes the shop to a search engine. A shop that
|
||||||
|
changes its Saturday hours changes them in Shop.java, once.
|
||||||
|
|
||||||
|
Both fragments take the classes that vary by where they sit — cream in the footer, sage on white in a
|
||||||
|
card — so the content is shared without the styling being averaged into something that suits neither.
|
||||||
|
*/-->
|
||||||
|
|
||||||
|
<ul th:fragment="hours(rowClass)" class="space-y-3">
|
||||||
|
<li th:each="span : ${hours}" th:class="'flex justify-between gap-4 ' + ${rowClass}">
|
||||||
|
<span th:text="${span.days}">Tuesday – Friday</span>
|
||||||
|
<span class="font-medium whitespace-nowrap" th:text="${span.hours}">7:00am – 2:00pm</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<address th:fragment="address(linkClass)" class="not-italic space-y-3">
|
||||||
|
<p th:text="${shopStreet}">215 E Main Street</p>
|
||||||
|
<p th:text="${shopTown}">Princeville, IL 61559</p>
|
||||||
|
<p>
|
||||||
|
<a th:href="|tel:${shopPhone}|" th:class="${linkClass}" th:text="${shopPhoneSpoken}">(309) 701-0660</a>
|
||||||
|
</p>
|
||||||
|
<p class="break-words">
|
||||||
|
<a th:href="|mailto:${shopEmail}|" th:class="${linkClass}" th:text="${shopEmail}">[email protected]</a>
|
||||||
|
</p>
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<!--/* "Open until 2:00pm", or when it opens next. The one thing a visitor at 2:30 on a Sunday wants. */-->
|
||||||
|
<!--/* The caller passes the display too (`inline-flex`, or `hidden lg:inline-flex`): setting it here as
|
||||||
|
well would be two rules for one property, and the winner would depend on Tailwind's output order
|
||||||
|
rather than on the markup. */-->
|
||||||
|
<span th:fragment="open-now(dotClass, textClass)"
|
||||||
|
th:class="'items-center gap-2 ' + ${textClass}">
|
||||||
|
<span th:class="'h-2 w-2 rounded-full shrink-0 ' + ${dotClass}" aria-hidden="true"></span>
|
||||||
|
<span th:text="${openNow.summary}">Open until 2:00pm</span>
|
||||||
|
</span>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content" class="bg-bakery-50">
|
||||||
|
|
||||||
|
<div th:replace="~{fragments/opening :: photographed(
|
||||||
|
'Since 2024',
|
||||||
|
'Our story',
|
||||||
|
'215 E Main Street, in the middle of downtown Princeville.',
|
||||||
|
'gallery/Outside.webp')}"></div>
|
||||||
|
|
||||||
|
<div class="container pb-16 md:pb-24">
|
||||||
|
<div class="measure space-y-12">
|
||||||
|
<section>
|
||||||
|
<h2 class="h3 text-bakery-900 mb-4">How it started</h2>
|
||||||
|
<p class="opening-para text-bakery-800 leading-relaxed">
|
||||||
|
Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown
|
||||||
|
Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep
|
||||||
|
enough tables that nobody feels rushed out the door.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="h3 text-bakery-900 mb-4">What we make</h2>
|
||||||
|
<p class="text-bakery-800 leading-relaxed">
|
||||||
|
We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls
|
||||||
|
and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and
|
||||||
|
paninis once the lunch crowd shows up.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<img th:src="${photos.of('gallery/Cinnamon Rolls.webp')}"
|
||||||
|
alt="Cinnamon rolls, iced, on the counter at The Vine"
|
||||||
|
width="1200" height="800" loading="lazy"
|
||||||
|
class="photo w-full h-64 md:h-80 object-cover">
|
||||||
|
<figcaption class="mt-3 label text-bakery-500 text-center">The cinnamon rolls, most mornings</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="h3 text-bakery-900 mb-4">The cakes are the fun part</h2>
|
||||||
|
<p class="text-bakery-800 leading-relaxed">
|
||||||
|
Cakes and decorated cookies are made to order, which means we mostly bake whatever
|
||||||
|
Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a
|
||||||
|
retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for
|
||||||
|
the cross country team and for a bridal party. If you can describe it, we will have a go
|
||||||
|
at it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<img th:src="${photos.of('gallery/Cakes.webp')}"
|
||||||
|
alt="A decorated cake made to order at The Vine"
|
||||||
|
width="1200" height="800" loading="lazy"
|
||||||
|
class="photo w-full h-64 md:h-80 object-cover">
|
||||||
|
<figcaption class="mt-3 label text-bakery-500 text-center">Made to order, whatever the week is celebrating</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="h3 text-bakery-900 mb-4">Around town</h2>
|
||||||
|
<p class="text-bakery-800 leading-relaxed">
|
||||||
|
We turn out for Christmas in the Village every year and for other civic events, and the
|
||||||
|
Princeville Civic Association counts us among the town's small businesses. Enjoy
|
||||||
|
Illinois and Discover Peoria have both pointed travelers our way. If you are one of them,
|
||||||
|
we open at 7:00am, Tuesday through Saturday.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* Somewhere to go after reading: the story is a preamble to an order. */-->
|
||||||
|
<section class="section bg-bakery-800 text-white">
|
||||||
|
<div class="container">
|
||||||
|
<div class="measure text-center">
|
||||||
|
<h2 class="h3">Come and see us</h2>
|
||||||
|
<p class="mt-4 text-bakery-100 leading-relaxed">
|
||||||
|
We open at 7:00am, Tuesday through Saturday. If you are after something for a date in the
|
||||||
|
diary, it is worth asking early.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<a href="/products"
|
||||||
|
class="pill-cream">
|
||||||
|
See what we make
|
||||||
|
</a>
|
||||||
|
<a href="/contact"
|
||||||
|
class="pill-ghost">
|
||||||
|
Get in touch
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content">
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
Hero — centred lockup on a sage wash.
|
||||||
|
|
||||||
|
56svh, not 78: at 78 a laptop's first screen was the lockup and nothing else, so the page looked like
|
||||||
|
it had one thing on it. The floor is low enough now that the next section shows above the fold and
|
||||||
|
invites the scroll, and high enough that the lockup still has room to be the first thing you see. It
|
||||||
|
is a floor, not a height — the content wins on a short window.
|
||||||
|
*/-->
|
||||||
|
<section class="relative flex items-center min-h-[56svh] py-16 md:py-20 bg-bakery-900 text-white overflow-hidden">
|
||||||
|
<!--/* Softened on purpose: the storefront's own painted sign sits right behind the logo, so a sharp
|
||||||
|
photo makes you read the name twice. scale-110 hides the blur's feathered edges. */-->
|
||||||
|
<img th:src="${photos.of('gallery/Outside.webp')}" alt="" fetchpriority="high"
|
||||||
|
class="absolute inset-0 w-full h-full object-cover object-bottom blur-[3px] scale-110">
|
||||||
|
<!--/* Sage wash rather than a neutral black scrim — the tint is the identity. */-->
|
||||||
|
<div class="absolute inset-0 bg-bakery-900/80"></div>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
|
||||||
|
|
||||||
|
<!--/* w-full/min-w-0 keep this flex item from sizing to its max-content width and blowing out the
|
||||||
|
page on narrow screens. */-->
|
||||||
|
<div class="relative container w-full min-w-0">
|
||||||
|
<div class="measure text-center">
|
||||||
|
<div th:replace="~{fragments/lockup :: large('text-bakery-50 mb-8')}"></div>
|
||||||
|
<p class="lede sm:text-xl text-bakery-100 mb-8">
|
||||||
|
A coffeehouse and bakery in downtown Princeville, Illinois.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<a href="/products"
|
||||||
|
class="pill-cream">
|
||||||
|
See the menu
|
||||||
|
</a>
|
||||||
|
<a href="#visit"
|
||||||
|
class="pill-ghost">
|
||||||
|
Visit us
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--/* What people come in for */-->
|
||||||
|
<section class="section bg-bakery-50">
|
||||||
|
<div class="container">
|
||||||
|
<div class="mb-12" th:replace="~{fragments/opening :: section-head('What people come in for', '')}"></div>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div th:each="item : ${ {'Cinnamon Rolls', 'Sugar Cookies', 'Cakes'} }"
|
||||||
|
class="panel overflow-hidden text-center">
|
||||||
|
<!--/* The filenames carry the spaces in these names; SitePhotos encodes them. */-->
|
||||||
|
<img th:src="${photos.of('gallery/' + item + '.webp')}" th:alt="${item}" width="600" height="400" loading="lazy"
|
||||||
|
class="w-full h-56 object-cover">
|
||||||
|
<h3 class="panel-head border-b-0 border-t h4 md:text-2xl text-bakery-800 py-6" th:text="${item}">Cinnamon Rolls</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
Ordering ahead. The catering page exists and the homepage said nothing about it — a customer only
|
||||||
|
found it by reading the nav. Photo on one side, the offer on the other, and the three tables named so
|
||||||
|
the link is worth following.
|
||||||
|
*/-->
|
||||||
|
<section class="section bg-bakery-100">
|
||||||
|
<div class="container">
|
||||||
|
<div class="grid md:grid-cols-2 gap-8 md:gap-12 items-center measure-wide">
|
||||||
|
<img th:src="${photos.of('gallery/Sugar Cookies.webp')}" alt="Iced sugar cookies from The Vine"
|
||||||
|
width="800" height="600" loading="lazy"
|
||||||
|
class="photo w-full h-64 md:h-80 object-cover">
|
||||||
|
<div>
|
||||||
|
<h2 class="h2 text-bakery-900">Ordering for a crowd?</h2>
|
||||||
|
<p class="mt-4 text-bakery-800 leading-relaxed">
|
||||||
|
Goodie boxes for the office, packages for a party, and cakes and desserts for a wedding —
|
||||||
|
with prices, sizes and what each one includes, so you can see where to start.
|
||||||
|
</p>
|
||||||
|
<a href="/catering"
|
||||||
|
class="pill-sage mt-6">
|
||||||
|
Goodie boxes & catering
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--/* Our story */-->
|
||||||
|
<section class="section bg-bakery-800 text-white">
|
||||||
|
<div class="container">
|
||||||
|
<div class="measure text-center">
|
||||||
|
<h2 class="h2">Our story</h2>
|
||||||
|
<p class="lede mt-4 text-bakery-100 mb-8">
|
||||||
|
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
|
||||||
|
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
|
||||||
|
</p>
|
||||||
|
<a href="/history" class="link-on-dark font-semibold">
|
||||||
|
Read our story
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!--/* Visit us */-->
|
||||||
|
<section id="visit" class="section bg-bakery-50 scroll-mt-24">
|
||||||
|
<div class="container">
|
||||||
|
<div class="mb-12" th:replace="~{fragments/opening :: section-head('Visit us', '')}"></div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 measure-wide">
|
||||||
|
<div class="panel p-6 md:p-8">
|
||||||
|
<h2 class="h3 text-bakery-900 mb-6">Our hours</h2>
|
||||||
|
<div th:replace="~{fragments/visit :: hours('text-bakery-800')}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel p-6 md:p-8">
|
||||||
|
<h2 class="h3 text-bakery-900 mb-6">Find us</h2>
|
||||||
|
<div class="text-bakery-800"
|
||||||
|
th:replace="~{fragments/visit :: address('link-plain')}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/page :: page(${title}, ${description}, ${path}, ~{::content})}">
|
||||||
|
<body>
|
||||||
|
<div th:fragment="content" class="min-h-screen bg-bakery-50">
|
||||||
|
|
||||||
|
<div th:replace="~{fragments/opening :: plain(
|
||||||
|
'From the counter',
|
||||||
|
'Our products',
|
||||||
|
'What comes out of the kitchen on Main Street. Cakes and decorated cookies are made to order, so most of what follows started as somebody describing what they wanted.')}"></div>
|
||||||
|
|
||||||
|
<div class="container pb-16">
|
||||||
|
<!--/*
|
||||||
|
The category filter: links, not buttons. Each filtered view is now a URL you can bookmark or send
|
||||||
|
to somebody, the browser's back button does what it looks like it does, and a crawler can see all
|
||||||
|
forty items instead of whichever twelve the default filter showed. The filtering itself has always
|
||||||
|
been the server's job — this just stopped pretending otherwise.
|
||||||
|
*/-->
|
||||||
|
<div class="filter-row mb-12">
|
||||||
|
<a th:each="category : ${categories}"
|
||||||
|
th:href="${category == 'All' ? '/products' : '/products?category=' + #uris.escapeQueryParam(category)}"
|
||||||
|
th:text="${category}"
|
||||||
|
th:aria-current="${category == selected ? 'page' : null}"
|
||||||
|
th:class="${category == selected} ? 'chip-on' : 'chip'">All</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* Deliberately unanimated, as before: filtering used to run a layout reflow plus an enter/exit
|
||||||
|
fade on every card, which on a 40-card grid reads as the page lurching rather than
|
||||||
|
responding. The only motion left is the shadow on hover. */-->
|
||||||
|
<div class="card-grid">
|
||||||
|
<div th:each="product : ${products}"
|
||||||
|
class="panel overflow-hidden">
|
||||||
|
<div class="relative w-full overflow-hidden">
|
||||||
|
<div th:replace="~{:: gallery(${product})}"></div>
|
||||||
|
</div>
|
||||||
|
<!--/* The caption sits on the cream, so the photo reads as mounted on the card rather than as
|
||||||
|
the card itself. */-->
|
||||||
|
<div class="panel-head border-b-0 border-t px-6 py-5 text-center">
|
||||||
|
<h3 class="h4 text-bakery-900" th:text="${product.name}">Name</h3>
|
||||||
|
<span class="label text-bakery-600" th:text="${product.category}">Category</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p th:if="${#lists.isEmpty(products)}" class="text-center text-bakery-800">
|
||||||
|
Nothing in that category just now.
|
||||||
|
<a href="/products" class="link-plain">See everything</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/* A catalogue with no way to order from it is a gallery. */-->
|
||||||
|
<section class="section bg-bakery-800 text-white no-print">
|
||||||
|
<div class="container">
|
||||||
|
<div class="measure text-center">
|
||||||
|
<h2 class="h3">Want one of these, or something else?</h2>
|
||||||
|
<p class="mt-4 text-bakery-100 leading-relaxed">
|
||||||
|
Cakes and decorated cookies are made to order. Tell us the date and roughly what you have in
|
||||||
|
mind — a tractor, a retirement, the class of 1964 — and we will work from there.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<a href="/contact"
|
||||||
|
class="pill-cream">
|
||||||
|
Ask about an order
|
||||||
|
</a>
|
||||||
|
<a href="/catering"
|
||||||
|
class="pill-ghost">
|
||||||
|
Ordering for a crowd
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/*
|
||||||
|
The photo on a card.
|
||||||
|
|
||||||
|
A scroll-snap strip rather than a stack of absolutely positioned images cross-fading: it is one square
|
||||||
|
photo at a time either way, but this version swipes on a phone and scrolls with a trackpad with no
|
||||||
|
script at all. /js/gallery.js adds the arrows and the dots on top. They are created there rather than
|
||||||
|
rendered here on purpose — a control that does nothing without JavaScript is worse than no control.
|
||||||
|
*/-->
|
||||||
|
<div th:fragment="gallery(product)" class="relative aspect-square bg-bakery-100">
|
||||||
|
<div class="gallery flex h-full w-full overflow-x-auto snap-x snap-mandatory no-scrollbar"
|
||||||
|
th:attr="aria-label=${#lists.size(product.images) > 1 ? product.name + ' — ' + #lists.size(product.images) + ' photos' : null}"
|
||||||
|
th:aria-roledescription="${#lists.size(product.images) > 1 ? 'carousel' : null}">
|
||||||
|
<img th:each="image, i : ${product.images}" th:src="${image}"
|
||||||
|
th:alt="${i.index == 0 ? product.name : ''}"
|
||||||
|
th:aria-hidden="${i.index == 0 ? null : 'true'}"
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
class="snap-center shrink-0 h-full w-full object-cover">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* The admin's controls.
|
||||||
|
*
|
||||||
|
* Component classes rather than utility strings repeated through the markup, because the admin is made
|
||||||
|
* of forms: there are dozens of buttons on a page and each one is its own <form>. The public pages keep
|
||||||
|
* their utilities inline — they each look different — but "a button in the admin" should be one decision
|
||||||
|
* in one place. These are the same buttons the React screen had, from the same class strings it composed.
|
||||||
|
*
|
||||||
|
* @utility, not `.btn { @apply … }` in a components layer: Tailwind v4 will only let you @apply a class
|
||||||
|
* it knows as a utility, and only a registered utility can take a variant — which `file:btn-secondary`
|
||||||
|
* on the photo pickers needs.
|
||||||
|
*/
|
||||||
|
@utility btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-1.5 rounded-field px-3 py-1.5 text-sm font-medium
|
||||||
|
transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-primary {
|
||||||
|
@apply btn bg-bakery-600 text-white hover:bg-bakery-700
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-secondary {
|
||||||
|
@apply btn border border-bakery-300 text-bakery-800 hover:bg-bakery-100
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-danger {
|
||||||
|
@apply btn text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Square, for the arrows and the crosses — a row of these is how anything gets reordered. */
|
||||||
|
@utility btn-icon {
|
||||||
|
@apply inline-flex items-center justify-center w-7 h-7 rounded-field border border-bakery-300
|
||||||
|
text-bakery-700 hover:bg-bakery-100 transition-colors
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `field` is shared with the public form — see components.css. */
|
||||||
|
|
||||||
|
@utility card {
|
||||||
|
@apply rounded-panel border border-bakery-200 bg-white p-4 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card-heading {
|
||||||
|
@apply font-adbhashitha text-xl text-bakery-800;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* Element defaults: the page background, the body type, the fade-in, and the two small resets the site
|
||||||
|
* needs. Anything with a class name belongs in components.css instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* Nothing on this site is meant to scroll sideways. */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bakery-50);
|
||||||
|
color: var(--color-bakery-900);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
overflow-x: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.5s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
body { animation: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-bakery-300);
|
||||||
|
color: var(--color-bakery-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The mobile menu is a <details>; its default disclosure triangle would sit beside the hamburger. Lost
|
||||||
|
once already in a refactor, which is exactly the kind of thing nobody notices until a phone. */
|
||||||
|
summary {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FOCUS. One visible ring for everything you can reach with a keyboard, as an outline rather than a
|
||||||
|
* shadow so it shows up on cream, on white and on the sage bands without needing three variants.
|
||||||
|
* :where() keeps its specificity at zero, so a utility can still override it.
|
||||||
|
*/
|
||||||
|
:where(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid var(--color-bakery-500);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On the sage bands that same sage is about 2.5:1 against the background — under the 3:1 a focus
|
||||||
|
* indicator needs, so the ring effectively disappeared exactly where the page's main calls to action
|
||||||
|
* live. Cream on those sections.
|
||||||
|
*/
|
||||||
|
:where(.bg-bakery-800, .bg-bakery-900) :where(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
|
||||||
|
outline-color: var(--color-bakery-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* THE Z-INDEX SCALE, in full. Two layers is all this site has, and writing them down is what stops a
|
||||||
|
* third being invented at 9999:
|
||||||
|
*
|
||||||
|
* 40 the sticky header
|
||||||
|
* 30 the mobile menu panel, which is a child of the header and therefore already inside its stacking
|
||||||
|
* context — it can only ever sit under the bar, which is exactly where it belongs
|
||||||
|
*
|
||||||
|
* Everything else stacks in document order: the hero's tint sits over its photo because it comes after
|
||||||
|
* it, and the product-card arrows sit over the photos for the same reason.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A native select still gets its own arrow from the platform, in the platform's colour. This is the one
|
||||||
|
* control the admin uses that we don't draw ourselves, so it gets the site's chevron instead.
|
||||||
|
*/
|
||||||
|
select.field {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234a5740' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.6rem center;
|
||||||
|
background-size: 1.1rem;
|
||||||
|
padding-right: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PRINT. The catering page is a price list, and a bakery prints price lists — so what comes out of a
|
||||||
|
* printer should be the prices, not a screenshot of a website. The chrome goes, the sage bands go (they
|
||||||
|
* would eat a cartridge), the cards keep their edges so the columns stay readable, and links stop being
|
||||||
|
* blue-and-underlined because paper has no links.
|
||||||
|
*/
|
||||||
|
@media print {
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
opacity: 1;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sage-on-white blocks become plain text: the words matter, the ink does not. */
|
||||||
|
[class*="bg-bakery-8"],
|
||||||
|
[class*="bg-bakery-9"] {
|
||||||
|
background: #fff !important;
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A card should not be split across a page break mid-price. */
|
||||||
|
article,
|
||||||
|
section {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/*
|
||||||
|
* The public site's own classes — the ones that would be unreadable as a string of utilities in every
|
||||||
|
* template that needs them. The admin's controls are in admin.css; the type ladder is in type.css.
|
||||||
|
*
|
||||||
|
* Almost everything here is @utility rather than a plain class in a layer, for two reasons Tailwind v4
|
||||||
|
* enforces: only a registered utility can be @applied by another rule (panel-mark builds on mark), and
|
||||||
|
* only a registered utility can take a variant (`hover:`, `md:`, `file:`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The page gutter, and the only plain class left in a layer.
|
||||||
|
*
|
||||||
|
* Every section used to write `container mx-auto px-4`, carried over from the React site: `mx-auto` twice
|
||||||
|
* and a px-4 that beat the responsive padding below, so the gutter was 16px at every width including a
|
||||||
|
* 27-inch screen. The markup now says `container` and nothing else, which is why this can stay in the
|
||||||
|
* components layer — there is no longer a utility next to it that has to win.
|
||||||
|
*/
|
||||||
|
@layer components {
|
||||||
|
.container {
|
||||||
|
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* MEASURES. Text you read wants a narrow column and a grid of cards wants a wide one; those two numbers
|
||||||
|
* were seven different max-w-* classes chosen a template at a time.
|
||||||
|
*/
|
||||||
|
@utility measure {
|
||||||
|
@apply max-w-2xl mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility measure-wide {
|
||||||
|
@apply max-w-5xl mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* LINKS in running text. Underlined, offset so the line clears the descenders, and colour-shifting on
|
||||||
|
* hover — three variants because a link in a paragraph, a link on a sage band and a quiet one in small
|
||||||
|
* print are the same idea in different rooms.
|
||||||
|
*/
|
||||||
|
@utility link {
|
||||||
|
@apply underline underline-offset-4 text-bakery-700 hover:text-bakery-900 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility link-on-dark {
|
||||||
|
@apply underline underline-offset-4 text-bakery-50 hover:text-white transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inherits its colour: for a link inside a coloured paragraph that already sets one. */
|
||||||
|
@utility link-plain {
|
||||||
|
@apply underline underline-offset-4 hover:text-bakery-600 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FIELDS. One input, everywhere. The public form had its own (cream, ring-2, py-2.5) and the admin had
|
||||||
|
* another (white, ring-1, py-2, text-sm), which is what happens when a form gets written twice.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* text-base on a phone, not text-sm: Safari zooms the whole page in when you focus an input smaller than
|
||||||
|
* 16px, which on the contact form means every enquiry typed on a phone starts by throwing the layout
|
||||||
|
* sideways. The denser size comes back at sm, where no browser does that.
|
||||||
|
*/
|
||||||
|
@utility field {
|
||||||
|
@apply w-full rounded-field border border-bakery-300 bg-white px-3.5 py-2.5 text-base sm:text-sm
|
||||||
|
focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A photograph standing on the page rather than inside a card: same edge and shadow as one. */
|
||||||
|
@utility photo {
|
||||||
|
@apply rounded-panel border border-bakery-200/70;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cream header at the top of a page that has no photographic band. */
|
||||||
|
@utility page-head {
|
||||||
|
@apply container pt-14 pb-10 md:pt-20 md:pb-12 text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* THE BRANCH MARKS of the wordmark, as masks rather than inline SVG.
|
||||||
|
*
|
||||||
|
* They have to take their colour from the surrounding text — sage in the header, cream on the hero and in
|
||||||
|
* the footer — which an <img> cannot do. React solved that by inlining them through svgr, but these files
|
||||||
|
* are 31 KB each and the lockup appears up to three times on a page: inlining them in server-rendered
|
||||||
|
* HTML would add ~190 KB to every response. A mask over `currentColor` keeps the tinting, keeps the files
|
||||||
|
* cacheable, and costs two requests once.
|
||||||
|
*/
|
||||||
|
@utility mark {
|
||||||
|
background-color: currentColor;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mark-r {
|
||||||
|
-webkit-mask-image: url('/images/logo_R.svg');
|
||||||
|
mask-image: url('/images/logo_R.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility mark-l {
|
||||||
|
-webkit-mask-image: url('/images/logo_L.svg');
|
||||||
|
mask-image: url('/images/logo_L.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The photo strip on a product card scrolls, but a scrollbar across the bottom of a photo is not part of
|
||||||
|
* the design. Hiding it is safe here because the strip is not the only way through the photos: it snaps,
|
||||||
|
* it takes arrow keys, and the arrows and dots are on top of it.
|
||||||
|
*/
|
||||||
|
@utility no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SURFACES.
|
||||||
|
*
|
||||||
|
* These were plain white boxes with a grey drop shadow — the default card of every web app, on a page
|
||||||
|
* that is otherwise cream, sage and a hand-drawn vine. Three things make them the shop's:
|
||||||
|
*
|
||||||
|
* a warm hairline, so a card has an EDGE and reads as stock rather than as a floating panel;
|
||||||
|
* a shadow tinted with the darkest sage instead of black (see theme.css);
|
||||||
|
* a head that can be tinted cream, the way a printed menu separates the price from what you get.
|
||||||
|
*
|
||||||
|
* `panel-lift` is one you can click, and it rises rather than just darkening.
|
||||||
|
*/
|
||||||
|
@utility panel {
|
||||||
|
@apply bg-white rounded-panel border border-bakery-200/70;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility panel-lift {
|
||||||
|
@apply panel transition duration-300;
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-card-lift);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cream band on a card: a size and its price, or a photo's caption. */
|
||||||
|
@utility panel-head {
|
||||||
|
@apply bg-bakery-50/70 border-b border-bakery-200/70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A card that is holding an aside rather than the thing you came for: the small print under a price
|
||||||
|
* table, a pointer to another page. Tinted and bordered, no shadow, so it sits ON the page instead of
|
||||||
|
* above it and does not compete with the cards it follows. There were two hand-rolled versions of this
|
||||||
|
* before it had a name, and they had different borders.
|
||||||
|
*/
|
||||||
|
@utility panel-quiet {
|
||||||
|
@apply rounded-panel border border-bakery-200/70 bg-bakery-50/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A grid of cards. It was written three times with three different sets of breakpoints and gaps — the
|
||||||
|
* homepage went to three columns at md, the catering tables at lg, the catalogue at lg with a wider gap —
|
||||||
|
* so cards on different pages lined up differently for no reason anyone chose.
|
||||||
|
*/
|
||||||
|
@utility card-grid {
|
||||||
|
@apply grid gap-6 md:gap-8 sm:grid-cols-2 lg:grid-cols-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The branch, laid into a corner at the weight of a watermark. It is the one mark that is unmistakably
|
||||||
|
* this shop, and at this size and colour it is texture rather than decoration.
|
||||||
|
*/
|
||||||
|
@utility panel-mark {
|
||||||
|
@apply mark mark-l absolute pointer-events-none text-bakery-200/70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* BUTTONS. One shape — a full pill, generous, letterspaced — in four colourways, because the site puts
|
||||||
|
* buttons on cream and on sage and they cannot be the same colour on both.
|
||||||
|
*
|
||||||
|
* pill-sage filled, on a light background pill-outline quiet, on a light background
|
||||||
|
* pill-cream filled, on a sage background pill-ghost quiet, on a sage background
|
||||||
|
*/
|
||||||
|
@utility pill {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-full
|
||||||
|
px-8 py-3.5 font-medium tracking-wide transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility pill-sage {
|
||||||
|
@apply pill bg-bakery-600 text-white hover:bg-bakery-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility pill-cream {
|
||||||
|
@apply pill bg-bakery-50 text-bakery-900 hover:bg-white shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility pill-outline {
|
||||||
|
@apply pill border border-bakery-300 text-bakery-800 hover:bg-bakery-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility pill-ghost {
|
||||||
|
@apply pill border border-bakery-200/60 text-bakery-50 hover:bg-bakery-50/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Smaller than a pill, for a filter row rather than a call to action — but still 44px tall, which is the
|
||||||
|
* smallest thing a thumb should be asked to hit.
|
||||||
|
*/
|
||||||
|
@utility chip {
|
||||||
|
@apply inline-flex items-center justify-center rounded-full border px-5 sm:px-6 min-h-11 label
|
||||||
|
whitespace-nowrap bg-white border-bakery-300 text-bakery-700 transition-colors hover:bg-bakery-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility chip-on {
|
||||||
|
@apply chip bg-bakery-600 border-bakery-600 text-white hover:bg-bakery-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* RHYTHM. Two vertical sizes, so the page has a beat instead of nine hand-picked paddings: `section` for
|
||||||
|
* a block of content, `band` for a photographic or coloured strip, which sits tighter because its
|
||||||
|
* background is already doing the separating.
|
||||||
|
*/
|
||||||
|
@utility section {
|
||||||
|
@apply py-16 md:py-24;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility band {
|
||||||
|
@apply py-14 md:py-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A row of filters that scrolls sideways on a phone instead of wrapping onto three lines with one item
|
||||||
|
* stranded on the last. It wraps and centres from sm, where there is room. The negative margin lets the
|
||||||
|
* strip bleed to the screen edge so a half-visible chip shows there is more.
|
||||||
|
*/
|
||||||
|
@utility filter-row {
|
||||||
|
@apply flex gap-3 overflow-x-auto no-scrollbar snap-x -mx-4 px-4
|
||||||
|
sm:flex-wrap sm:justify-center sm:gap-4 sm:overflow-visible sm:mx-0 sm:px-0;
|
||||||
|
& > * {
|
||||||
|
@apply snap-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* THE PAGE YOU ARE ON, in the nav. A rule under the word rather than a colour change: the nav is already
|
||||||
|
* sage on cream, and darkening it further reads as a hover, not as "you are here".
|
||||||
|
*/
|
||||||
|
@utility is-here {
|
||||||
|
@apply text-bakery-900;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--color-bakery-500);
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The keyboard's way past the nav. Off-screen until focused, then a normal-looking button in the corner —
|
||||||
|
* the pattern only works if it becomes visible, which is the half everyone forgets.
|
||||||
|
*/
|
||||||
|
@utility skip-link {
|
||||||
|
@apply sr-only;
|
||||||
|
&:focus {
|
||||||
|
@apply not-sr-only fixed left-4 top-4 z-50 pill-sage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An opening paragraph set like a printed one. Used once, on the story — a drop cap on every page would
|
||||||
|
* be a costume rather than a voice.
|
||||||
|
*/
|
||||||
|
@utility opening-para {
|
||||||
|
&::first-letter {
|
||||||
|
@apply font-adbhashitha text-6xl text-bakery-700 float-left mr-3 mt-1;
|
||||||
|
line-height: 0.75;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "itsthevine-styles",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --minify",
|
||||||
|
"build:css": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --minify",
|
||||||
|
"watch": "tailwindcss -i site.css -o ../../../target/classes/static/css/site.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "4.3.3",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* The site's one stylesheet, compiled by the Tailwind CLI into target/classes/static/css.
|
||||||
|
*
|
||||||
|
* It lives under src/main with the rest of the app's source, and it has to stay in the same directory as
|
||||||
|
* package.json: Tailwind resolves `@import "tailwindcss"` by walking up from the CSS file looking for
|
||||||
|
* node_modules. There is no bundler and no framework here — the site and the admin are both
|
||||||
|
* server-rendered HTML, and this is the whole of the asset pipeline.
|
||||||
|
*
|
||||||
|
* Imported in cascade order: names, then bare elements, then classes.
|
||||||
|
*/
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@import "./theme.css";
|
||||||
|
@import "./base.css";
|
||||||
|
@import "./type.css";
|
||||||
|
@import "./components.css";
|
||||||
|
@import "./admin.css";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Where Tailwind looks for class names. Every template, and gallery.js — the product-card arrows are
|
||||||
|
* created in script, so their classes are only written down there. A utility exists in the output only if
|
||||||
|
* Tailwind saw it in one of these files, which is why a class name must never be assembled from pieces at
|
||||||
|
* runtime.
|
||||||
|
*/
|
||||||
|
@source "../resources/templates";
|
||||||
|
@source "../resources/static/js";
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* The brand: three typefaces and the sage-and-cream palette. Nothing here draws anything — these are the
|
||||||
|
* names the rest of the CSS and every template are written in.
|
||||||
|
*
|
||||||
|
* Fonts are served by Spring from /fonts rather than bundled, so the stylesheet can name a stable URL and
|
||||||
|
* the browser caches one copy across the whole site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Raleway';
|
||||||
|
src: url('/fonts/raleway-latin.woff2') format('woff2');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 306 KB became 8 KB. The file carried 1605 glyphs for 187 codepoints — alternates and scripts this site
|
||||||
|
* never sets — so it is subset to Latin and the punctuation it actually has, with every layout feature
|
||||||
|
* kept, and compressed as woff2. The headings are pixel-identical; that was checked, not assumed.
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AdBhashitha';
|
||||||
|
src: url('/fonts/AdBhashitha.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Recompressed, never subset: the wordmark's swashes come out of this font's alternates, and the logo is
|
||||||
|
* the one thing on the site that must not change shape.
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'LeJour Script';
|
||||||
|
src: url('/fonts/LeJour-Script.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Sage & Cream. 500 is the signature sage; 600+ are the darker tones that white text can actually
|
||||||
|
sit on (500 on white is only 3.6:1 — too low). */
|
||||||
|
--color-bakery-50: #faf7f0;
|
||||||
|
--color-bakery-100: #f0efe3;
|
||||||
|
--color-bakery-200: #dde0cc;
|
||||||
|
--color-bakery-300: #c3cbae;
|
||||||
|
--color-bakery-400: #a2ae8b;
|
||||||
|
--color-bakery-500: #7c8b6b;
|
||||||
|
--color-bakery-600: #5f6f52;
|
||||||
|
--color-bakery-700: #4a5740;
|
||||||
|
--color-bakery-800: #37412f;
|
||||||
|
--color-bakery-900: #232b1e;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CORNERS. Two decisions, named, so "how round is a card" is answered once rather than per template.
|
||||||
|
*
|
||||||
|
* A card is 12px. It was 24px (Tailwind's rounded-3xl), which is the roundest thing on the page and
|
||||||
|
* reads as a phone app; a bakery's printed card has a corner you can see. Fields are tighter again, and
|
||||||
|
* buttons stay fully round — the pill is the brand's button shape and always was.
|
||||||
|
*/
|
||||||
|
--radius-panel: 0.75rem;
|
||||||
|
--radius-field: 0.5rem;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shadows tinted with the darkest sage rather than black. A neutral grey shadow on a cream page reads
|
||||||
|
* as a UI panel floating over a screen; a warm one reads as card stock lying on a counter, which is
|
||||||
|
* what these are meant to be.
|
||||||
|
*/
|
||||||
|
--shadow-card: 0 1px 2px rgba(35, 43, 30, 0.04), 0 10px 30px -18px rgba(35, 43, 30, 0.28);
|
||||||
|
--shadow-card-lift: 0 2px 6px rgba(35, 43, 30, 0.06), 0 20px 44px -22px rgba(35, 43, 30, 0.4);
|
||||||
|
|
||||||
|
--font-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif;
|
||||||
|
--font-lejour: 'LeJour Script', ui-serif, Georgia, cursive;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* The typographic ladder, taken from the logo and walked down to something you can read a paragraph in.
|
||||||
|
*
|
||||||
|
* The lockup is the brand's whole voice: "The Vine" in LeJour Script over COFFEEHOUSE + BAKERY in
|
||||||
|
* AdBhashitha, letterspaced. Everything below is that idea, made progressively more readable:
|
||||||
|
*
|
||||||
|
* 1. .wordmark LeJour Script the name, and nothing else. A script face is unreadable at length,
|
||||||
|
* so it never leaves the lockup.
|
||||||
|
* 2. .h1 .h2 .h3 AdBhashitha the lockup's second font, carrying every heading. It keeps the
|
||||||
|
* wordmark's 0.01em letter-spacing so a heading sits on the same
|
||||||
|
* rhythm as the logo above it.
|
||||||
|
* 3. .eyebrow Raleway, caps the hinge: sans, but letterspaced like the lockup's tagline. Used
|
||||||
|
* .label above a heading and on small labels, it carries the brand into
|
||||||
|
* places a display face would be shouting.
|
||||||
|
* 4. .lede, body Raleway plain, and left alone.
|
||||||
|
*
|
||||||
|
* Sizes are declared here rather than in the templates so a heading is a decision made once. None of
|
||||||
|
* these set a colour: the same heading appears in sage on cream and in cream on sage, and a class that
|
||||||
|
* decided that would be fought with a utility every time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@utility wordmark {
|
||||||
|
font-family: var(--font-lejour);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility h1 {
|
||||||
|
@apply font-adbhashitha text-4xl md:text-5xl leading-tight text-balance;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility h2 {
|
||||||
|
@apply font-adbhashitha text-3xl md:text-4xl leading-tight text-balance;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility h3 {
|
||||||
|
@apply font-adbhashitha text-2xl md:text-3xl leading-snug;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility h4 {
|
||||||
|
@apply font-adbhashitha text-xl leading-snug;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Above a heading, or as a section's own small heading. */
|
||||||
|
@utility eyebrow {
|
||||||
|
@apply text-xs sm:text-sm uppercase font-medium;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smaller, tighter: a column heading, a "good to know", a category chip. */
|
||||||
|
@utility label {
|
||||||
|
@apply text-xs uppercase font-medium;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The sentence under a page title. One size up from body, and balanced so it doesn't leave an orphan. */
|
||||||
|
@utility lede {
|
||||||
|
@apply text-base sm:text-lg leading-relaxed text-balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A price. The display face at a size nothing else uses, because on a catering card the number is the
|
||||||
|
* thing the eye should land on after the size.
|
||||||
|
*/
|
||||||
|
@utility price {
|
||||||
|
@apply font-adbhashitha text-4xl leading-none;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.context.WebApplicationContext;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin, signed in and driven the way a browser drives it: form posts and redirects.
|
||||||
|
*
|
||||||
|
* <p>These replace the JSON admin's tests. The screen used to be React talking to {@code
|
||||||
|
* /api/admin/**}; it is now Thymeleaf forms, so what is worth asserting is that a form arrives bound
|
||||||
|
* correctly, that an edit lands, that a refusal comes back readable rather than as a stack trace, and
|
||||||
|
* that a structural button changes the draft without writing it.
|
||||||
|
*
|
||||||
|
* <p>Runs with {@code SECURITY_MODE=OIDC}, because that is the only condition under which the admin
|
||||||
|
* exists at all.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"SECURITY_MODE=OIDC",
|
||||||
|
// Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the
|
||||||
|
// discovery document at startup — that needs the network and a real identity provider.
|
||||||
|
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]",
|
||||||
|
"platform.storage.access-key=test",
|
||||||
|
"platform.storage.secret-key=test"
|
||||||
|
})
|
||||||
|
@Testcontainers
|
||||||
|
// Rolled back per test, so each starts from the seeded catalogue. MockMvc runs the controller on this
|
||||||
|
// thread, which is what lets the test's transaction wrap the whole request.
|
||||||
|
@Transactional
|
||||||
|
class AdminPagesTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
static PostgreSQLContainer<?> postgres =
|
||||||
|
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
WebApplicationContext context;
|
||||||
|
|
||||||
|
MockMvc mvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out.
|
||||||
|
mvc = MockMvcBuilders.webAppContextSetup(context)
|
||||||
|
.apply(SecurityMockMvcConfigurers.springSecurity())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theCatalogueScreenShowsWhatIsOnThePageWithItsPhotos() throws Exception {
|
||||||
|
mvc.perform(get("/admin").with(user("morissa")))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("76th Birthday Cake")))
|
||||||
|
// The photo URL and the key beside it: the key is what the arrange buttons name it by.
|
||||||
|
.andExpect(content().string(containsString("products/76th_birthday_cake.webp")))
|
||||||
|
.andExpect(content().string(containsString("On the page (40)")))
|
||||||
|
// The filter list, with the count that decides whether Delete is offered.
|
||||||
|
.andExpect(content().string(containsString("Cookies")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renamingAnItemLandsAndSaysSo() throws Exception {
|
||||||
|
mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "save")
|
||||||
|
.param("name", "76th Birthday Cake (chocolate)")
|
||||||
|
.param("category", "Cakes"))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/admin"))
|
||||||
|
.andExpect(flash().attribute("done", containsString("Saved")));
|
||||||
|
|
||||||
|
mvc.perform(get("/admin").with(user("morissa")))
|
||||||
|
.andExpect(content().string(containsString("76th Birthday Cake (chocolate)")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aRefusalComesBackAsASentenceTheEditorCanActOn() throws Exception {
|
||||||
|
// Cookies has items filed under it, and deleting the button shouldn't decide what happens to them.
|
||||||
|
mvc.perform(post("/admin/categories/1").with(user("morissa")).with(csrf()).param("do", "delete"))
|
||||||
|
.andExpect(redirectedUrl("/admin"))
|
||||||
|
.andExpect(flash().attribute("problem", containsString("still filed under Cookies")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void movingAnItemUpFromTheTopIsNotAnError() throws Exception {
|
||||||
|
// The button is disabled in the page, but a stale page could still post this.
|
||||||
|
mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf()).param("do", "move:-1"))
|
||||||
|
.andExpect(redirectedUrl("/admin"))
|
||||||
|
.andExpect(flash().attributeCount(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theTableEditorRendersTheStoredTableAsAForm() throws Exception {
|
||||||
|
mvc.perform(get("/admin/catering/tables/1").with(user("morissa")))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
// Indexed names are what let Spring bind the grid back into the right cells.
|
||||||
|
.andExpect(content().string(containsString("name=\"columns[0].label\"")))
|
||||||
|
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
||||||
|
.andExpect(content().string(containsString("value=\"18 items\"")))
|
||||||
|
// The price round-trips as text: it came out "$24" and goes back the same way.
|
||||||
|
.andExpect(content().string(containsString("value=\"$24\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savingTheTableWritesEveryCell() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "save")
|
||||||
|
.param("name", "Office boxes")
|
||||||
|
.param("blurb", "For meetings.")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$26")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
.param("lines[0].values[0]", "14 items")
|
||||||
|
.param("notes[0]", "Two days' notice, please."))
|
||||||
|
.andExpect(redirectedUrl("/admin/catering"))
|
||||||
|
.andExpect(flash().attribute("done", containsString("Saved the Office boxes table")));
|
||||||
|
|
||||||
|
mvc.perform(get("/api/catering"))
|
||||||
|
.andExpect(content().string(containsString("Office boxes")))
|
||||||
|
.andExpect(content().string(containsString("$26")))
|
||||||
|
.andExpect(content().string(containsString("14 items")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addingAColumnKeepsWhatWasTypedAndWritesNothing() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "add-column")
|
||||||
|
.param("name", "Office")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
// A cell edited but not yet saved: it has to survive the round trip.
|
||||||
|
.param("lines[0].values[0]", "13 items"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("value=\"13 items\"")))
|
||||||
|
// The new column exists in the form, and every line grew an entry to match it.
|
||||||
|
.andExpect(content().string(containsString("name=\"columns[1].label\"")))
|
||||||
|
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
||||||
|
.andExpect(content().string(containsString("Not saved yet")));
|
||||||
|
|
||||||
|
// And nothing was written: the live page still says what it said.
|
||||||
|
mvc.perform(get("/api/catering"))
|
||||||
|
.andExpect(content().string(containsString("12 items")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removingAColumnTakesItsCellsOutOfEveryLine() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "remove-column:0")
|
||||||
|
.param("name", "Office")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
|
||||||
|
.param("columns[1].id", "2").param("columns[1].label", "Medium").param("columns[1].price", "$32")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
.param("lines[0].values[0]", "12 items")
|
||||||
|
.param("lines[0].values[1]", "18 items"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
// What is left is the Medium column and, under it, Medium's entry — not Small's.
|
||||||
|
.andExpect(content().string(containsString("value=\"Medium\"")))
|
||||||
|
.andExpect(content().string(containsString("value=\"18 items\"")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("value=\"12 items\""))))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("name=\"columns[1].label\""))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPriceThatIsntAPriceComesBackWithTheWorkStillInTheForm() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "save")
|
||||||
|
.param("name", "Office")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "ask us")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
.param("lines[0].values[0]", "12 items"))
|
||||||
|
// Not a redirect: a redirect would throw the work away and leave them guessing.
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("isn't a price")))
|
||||||
|
.andExpect(content().string(containsString("value=\"ask us\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thePageNotesAreReplacedByWhatTheFormSubmits() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/notes").with(user("morissa")).with(csrf())
|
||||||
|
.param("notes", "Prices may change.")
|
||||||
|
.param("notes", " ")
|
||||||
|
.param("notes", "Two weeks' notice for a wedding."))
|
||||||
|
.andExpect(redirectedUrl("/admin/catering"));
|
||||||
|
|
||||||
|
mvc.perform(get("/api/catering"))
|
||||||
|
.andExpect(content().string(containsString("Two weeks' notice for a wedding.")))
|
||||||
|
// The blank box was a deletion, not a note: two notes came back, not three.
|
||||||
|
.andExpect(content().string(containsString("Prices may change.")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("\" \""))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,13 +69,25 @@ class AdminSecurityTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyAdminApiIsClosedToAnonymousVisitors() throws Exception {
|
void everyAdminWriteIsClosedToAnonymousVisitors() throws Exception {
|
||||||
// csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
|
// csrf() on the writes, so these assert AUTHORIZATION, not a missing token. The admin is pages
|
||||||
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
|
// and form posts now, so this is the whole surface — there is no JSON admin left to guard.
|
||||||
mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
|
mvc.perform(get("/admin")).andExpect(status().isUnauthorized());
|
||||||
mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
|
mvc.perform(get("/admin/catering")).andExpect(status().isUnauthorized());
|
||||||
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
mvc.perform(get("/admin/catering/tables/1")).andExpect(status().isUnauthorized());
|
||||||
.content("{\"name\":\"x\"}"))
|
mvc.perform(post("/admin/items").with(csrf()).param("name", "Free cake").param("category", "Cakes"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(post("/admin/items/1").with(csrf()).param("do", "delete"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(post("/admin/categories").with(csrf()).param("name", "x"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
// The prices are the one thing on this site a stranger would most enjoy editing.
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(csrf())
|
||||||
|
.param("do", "save").param("name", "Free")
|
||||||
|
.param("columns[0].label", "Any").param("columns[0].price", "0")
|
||||||
|
.param("lines[0].label", "Everything").param("lines[0].values[0]", "yes"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(post("/admin/catering/notes").with(csrf()).param("notes", "anything"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +95,12 @@ class AdminSecurityTest {
|
|||||||
void theAdminPageRedirectsABrowserToLogin() throws Exception {
|
void theAdminPageRedirectsABrowserToLogin() throws Exception {
|
||||||
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
|
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
|
||||||
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
|
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
|
||||||
// the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this
|
// the platform answers */* with a bare 401, which is why the checks above see 401 and this one
|
||||||
// must send a browser's Accept header to see the 302. (Verified against a running container.)
|
// has to send a browser's Accept header to see the 302. (Verified against a running container.)
|
||||||
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
|
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
|
||||||
.andExpect(status().is3xxRedirection());
|
.andExpect(status().is3xxRedirection());
|
||||||
|
mvc.perform(get("/admin/catering").header("Accept", "text/html,application/xhtml+xml"))
|
||||||
|
.andExpect(status().is3xxRedirection());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -94,12 +108,14 @@ class AdminSecurityTest {
|
|||||||
// Locking the admin must not lock the menu.
|
// Locking the admin must not lock the menu.
|
||||||
mvc.perform(get("/api/products")).andExpect(status().isOk());
|
mvc.perform(get("/api/products")).andExpect(status().isOk());
|
||||||
mvc.perform(get("/api/categories")).andExpect(status().isOk());
|
mvc.perform(get("/api/categories")).andExpect(status().isOk());
|
||||||
|
mvc.perform(get("/api/catering")).andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void theContactFormStillNeedsItsCsrfToken() throws Exception {
|
void theContactFormStillNeedsItsCsrfToken() throws Exception {
|
||||||
// Enabling the security starter enables CSRF for the PUBLIC contact form too. Without the token
|
// Enabling the security starter enables CSRF for the PUBLIC contact form too. Without a token it
|
||||||
// it 403s; the SPA reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN.
|
// 403s. The page's own form carries a hidden field (Spring Security fills it in for any th:action
|
||||||
|
// form); this JSON endpoint needs the X-XSRF-TOKEN header from the cookie.
|
||||||
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\":\"Ada\",\"email\":\"[email protected]\",\"message\":\"hi\"}"))
|
.content("{\"name\":\"Ada\",\"email\":\"[email protected]\",\"message\":\"hi\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
import com.itsthevine.web.domain.Money;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catering tables, against the real seeded spreadsheet — so the migration is covered too.
|
||||||
|
*
|
||||||
|
* <p>Every test that writes runs inside the test's own transaction and is rolled back, so the seeded
|
||||||
|
* page is the same for each one.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
@Transactional
|
||||||
|
class CateringMenuTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static final PostgreSQLContainer<?> POSTGRES =
|
||||||
|
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void datasource(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||||
|
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||||
|
// The contact starter refuses to start on a blank recipient, and this app has a
|
||||||
|
// ContactController, so the context needs one even to test the catering page.
|
||||||
|
registry.add("platform.contact.to", () -> "[email protected]");
|
||||||
|
registry.add("platform.contact.from", () -> "[email protected]");
|
||||||
|
registry.add("platform.storage.access-key", () -> "test");
|
||||||
|
registry.add("platform.storage.secret-key", () -> "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
CateringMenu catering;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
EntityManager entityManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void carriesTheBakerysSpreadsheetIntoTheDatabase() {
|
||||||
|
// The prices, the sizes and the shape are the spreadsheet's (V4). The wording is not: V6 rewrote
|
||||||
|
// the cells as sentences a customer can read, since "6+6" and "4 dz" were notes to the baker.
|
||||||
|
List<CateringMenu.PackageView> tables = catering.menu().packages();
|
||||||
|
|
||||||
|
assertThat(tables).extracting(CateringMenu.PackageView::name)
|
||||||
|
.containsExactly("Office boxes", "Parties", "Weddings");
|
||||||
|
|
||||||
|
CateringMenu.PackageView office = tables.get(0);
|
||||||
|
assertThat(office.tiers()).extracting(CateringMenu.TierView::label)
|
||||||
|
.containsExactly("Small", "Medium", "Large");
|
||||||
|
assertThat(office.tiers()).extracting(CateringMenu.TierView::price)
|
||||||
|
.containsExactly("$24", "$32", "$40");
|
||||||
|
// One line, not three: a Small box is twelve items mixed in sixes, not twelve of each thing. The
|
||||||
|
// choice is written into the line's name, which is the same rule the other two tables follow.
|
||||||
|
assertThat(office.rows()).extracting(CateringMenu.RowView::label)
|
||||||
|
.containsExactly("Any mix of mini muffins, mini scones and mini cinnamon rolls");
|
||||||
|
assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items");
|
||||||
|
// "Small" says nothing about how many it feeds; this does.
|
||||||
|
assertThat(office.tiers()).extracting(CateringMenu.TierView::serves)
|
||||||
|
.containsExactly("About 6–8 people", "About 10–12 people", "About 15–20 people");
|
||||||
|
// The party and wedding columns are named by head count already, so they don't repeat it.
|
||||||
|
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::serves).containsOnlyNulls();
|
||||||
|
assertThat(office.blurb()).contains("morning meeting");
|
||||||
|
assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("Baked in sixes"));
|
||||||
|
|
||||||
|
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label)
|
||||||
|
.containsExactly("15–20 people", "20–30 people", "30–40 people");
|
||||||
|
assertThat(tables.get(2).tiers()).extracting(CateringMenu.TierView::price)
|
||||||
|
.containsExactly("$236", "$310", "$386");
|
||||||
|
// Wedding delivery terms belong to the wedding table, not to the page.
|
||||||
|
assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee"));
|
||||||
|
// No cell anywhere still speaks in shorthand.
|
||||||
|
assertThat(tables).allSatisfy(table -> assertThat(table.rows()).allSatisfy(row ->
|
||||||
|
assertThat(row.values()).noneMatch(value -> value.matches(".*\\b(dz|B&G|in cake)\\b.*"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theWeddingLinesEachSaySomething() {
|
||||||
|
// The source spreadsheet is offset — the prices sit on its "cupcakes" line — and V4 carried that
|
||||||
|
// over literally, which left two lines with no quantity in any column: a row of dashes on the
|
||||||
|
// page. V5 reads it as a baker would (a 12x17 pan is the sheet pan; "cc or sc" is what the dozens
|
||||||
|
// count) and the guards in it mean an edit made in the admin since would have survived instead.
|
||||||
|
CateringMenu.PackageView weddings = catering.menu().packages().get(2);
|
||||||
|
|
||||||
|
assertThat(weddings.rows()).extracting(CateringMenu.RowView::label)
|
||||||
|
.containsExactly("Bride & groom cake", "Sheet cakes or 12×17 bars",
|
||||||
|
"Cupcakes or sugar cookies");
|
||||||
|
assertThat(weddings.rows().get(1).values()).containsExactly("Two pans", "Three pans", "Four pans");
|
||||||
|
assertThat(weddings.rows().get(2).values()).containsExactly("Four dozen", "Five dozen", "Six dozen");
|
||||||
|
// Nothing left that is blank the whole way across.
|
||||||
|
assertThat(weddings.rows())
|
||||||
|
.noneMatch(row -> row.values().stream().allMatch(String::isEmpty));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thePartyLinesEachSaySomethingToo() {
|
||||||
|
// Same offset as the wedding table: the quantities on "Cupcakes" were always
|
||||||
|
// cupcakes-or-sugar-cookies, with an empty "Sugar cookies" line beneath.
|
||||||
|
CateringMenu.PackageView parties = catering.menu().packages().get(1);
|
||||||
|
assertThat(parties.rows()).extracting(CateringMenu.RowView::label)
|
||||||
|
.containsExactly("Cake", "Cupcakes or sugar cookies");
|
||||||
|
assertThat(parties.rows().get(0).values())
|
||||||
|
.containsExactly("A 6-inch cake", "An 8-inch cake", "A 10-inch cake");
|
||||||
|
assertThat(parties.rows())
|
||||||
|
.noneMatch(row -> row.values().stream().allMatch(String::isEmpty));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyLineCarriesOneEntryPerColumn() {
|
||||||
|
// The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is
|
||||||
|
// advertised at another box's price.
|
||||||
|
assertThat(catering.everything().packages()).allSatisfy(table ->
|
||||||
|
assertThat(table.rows()).allSatisfy(row ->
|
||||||
|
assertThat(row.values()).hasSameSizeAs(table.tiers())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsBlankCellsRatherThanCollapsingThem() {
|
||||||
|
// No seeded line is blank across any more, so this uses a table of its own making: a blank cell
|
||||||
|
// still has to survive a save, because dropping one would shorten the line and shift the rest.
|
||||||
|
CateringMenu.PackageView fresh = catering.add("Trays");
|
||||||
|
catering.save(fresh.id(), new CateringMenu.PackageEdit("Trays", null,
|
||||||
|
List.of(new CateringMenu.TierEdit(null, "Small", "10", null),
|
||||||
|
new CateringMenu.TierEdit(null, "Large", "20", null)),
|
||||||
|
List.of(new CateringMenu.RowEdit(null, "Seasonal extras", List.of("", "A dozen"))),
|
||||||
|
List.of()));
|
||||||
|
|
||||||
|
CateringMenu.RowView row = catering.everything().packages().getLast().rows().getFirst();
|
||||||
|
assertThat(row.values()).containsExactly("", "A dozen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statesThePageWideTermsSeparatelyFromAnyOneTable() {
|
||||||
|
assertThat(catering.menu().notes()).hasSize(2);
|
||||||
|
assertThat(catering.menu().notes().get(0)).contains("price may change");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesMoneyTheWayAPriceListDoes() {
|
||||||
|
assertThat(Money.format(2400)).isEqualTo("$24");
|
||||||
|
assertThat(Money.format(23600)).isEqualTo("$236");
|
||||||
|
assertThat(Money.format(2450)).isEqualTo("$24.50");
|
||||||
|
assertThat(Money.format(2405)).isEqualTo("$24.05");
|
||||||
|
assertThat(Money.format(150000)).isEqualTo("$1,500");
|
||||||
|
// "Ask us" is a legitimate price. It must not render as "$0".
|
||||||
|
assertThat(Money.format(null)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void takesAPriceHoweverTheBakeryTypesIt() {
|
||||||
|
assertThat(Money.cents("24")).isEqualTo(2400);
|
||||||
|
assertThat(Money.cents("$24")).isEqualTo(2400);
|
||||||
|
assertThat(Money.cents(" $1,250.00 ")).isEqualTo(125000);
|
||||||
|
// The float route gives 2410.0000000000005 for this one.
|
||||||
|
assertThat(Money.cents("24.10")).isEqualTo(2410);
|
||||||
|
assertThat(Money.cents("")).isNull();
|
||||||
|
assertThat(Money.cents(null)).isNull();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> Money.cents("ask us"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("isn't a price");
|
||||||
|
assertThatThrownBy(() -> Money.cents("24.005"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("go to the cent");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void droppingAColumnTakesItsValuesWithIt() {
|
||||||
|
CateringMenu.PackageView office = catering.everything().packages().get(0);
|
||||||
|
Long medium = office.tiers().get(1).id();
|
||||||
|
|
||||||
|
catering.save(office.id(), withoutColumn(office, 1));
|
||||||
|
// Straight back to the database: the point of this test is the rows that were deleted, and a
|
||||||
|
// session cache would happily show the right answer without them having been.
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
CateringMenu.PackageView saved = catering.everything().packages().get(0);
|
||||||
|
assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large");
|
||||||
|
assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium);
|
||||||
|
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "24 items");
|
||||||
|
assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesATableWhoseLinesAndColumnsDisagree() {
|
||||||
|
CateringMenu.PackageView office = catering.everything().packages().get(0);
|
||||||
|
// A column removed but the values left alone — the mistake that would shift every price.
|
||||||
|
CateringMenu.PackageEdit half = new CateringMenu.PackageEdit(
|
||||||
|
office.name(), office.blurb(),
|
||||||
|
asEdits(office).subList(0, 2),
|
||||||
|
asLines(office),
|
||||||
|
office.notes());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> catering.save(office.id(), half))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("Any mix of mini muffins")
|
||||||
|
.hasMessageContaining("3 entries but the table has 2 columns");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editingATableKeepsTheLinesItAlreadyHad() {
|
||||||
|
CateringMenu.PackageView office = catering.everything().packages().get(0);
|
||||||
|
Long muffins = office.rows().get(0).id();
|
||||||
|
|
||||||
|
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(office));
|
||||||
|
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("12 items", "20 items", "24 items")));
|
||||||
|
catering.save(office.id(), new CateringMenu.PackageEdit(
|
||||||
|
"Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes()));
|
||||||
|
|
||||||
|
CateringMenu.PackageView saved = catering.everything().packages().get(0);
|
||||||
|
assertThat(saved.name()).isEqualTo("Office boxes");
|
||||||
|
assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings.");
|
||||||
|
// Same line, edited — not a new line that happens to read the same.
|
||||||
|
assertThat(saved.rows().get(0).id()).isEqualTo(muffins);
|
||||||
|
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "20 items", "24 items");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNewTableStaysOffThePublicPageUntilItSaysSomething() {
|
||||||
|
CateringMenu.PackageView fresh = catering.add("Holiday boxes");
|
||||||
|
|
||||||
|
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
|
.containsExactly("Office boxes", "Parties", "Weddings", "Holiday boxes");
|
||||||
|
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
|
.doesNotContain("Holiday boxes");
|
||||||
|
|
||||||
|
// Once it has a column and a line, it's a price table and it belongs on the page.
|
||||||
|
catering.save(fresh.id(), new CateringMenu.PackageEdit("Holiday boxes", null,
|
||||||
|
List.of(new CateringMenu.TierEdit(null, "Dozen", "18", "About 6 people")),
|
||||||
|
List.of(new CateringMenu.RowEdit(null, "Frosted cut-outs", List.of("12 items"))),
|
||||||
|
List.of()));
|
||||||
|
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
|
.contains("Holiday boxes");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletingATableTakesItsColumnsLinesAndCellsWithIt() {
|
||||||
|
CateringMenu.PackageView weddings = catering.everything().packages().get(2);
|
||||||
|
|
||||||
|
catering.remove(weddings.id());
|
||||||
|
// Flushed on purpose: a delete that leaves its children behind fails against the real foreign
|
||||||
|
// keys, not in memory, and this one is a button on the admin screen.
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
|
.containsExactly("Office boxes", "Parties");
|
||||||
|
// The page's own terms outlive any one table.
|
||||||
|
assertThat(catering.menu().notes()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void putsTheTablesWhereTheEditorLeftThem() {
|
||||||
|
List<CateringMenu.PackageView> tables = catering.everything().packages();
|
||||||
|
List<Long> weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id());
|
||||||
|
|
||||||
|
assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name)
|
||||||
|
.containsExactly("Weddings", "Office boxes", "Parties");
|
||||||
|
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
|
.containsExactly("Weddings", "Office boxes", "Parties");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesPricesAndHeadingsThatCantBeRight() {
|
||||||
|
CateringMenu.PackageView office = catering.everything().packages().get(0);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "-1")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("less than nothing");
|
||||||
|
assertThatThrownBy(() -> catering.save(office.id(), withColumnPrice(office, "$50,000")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("$10,000");
|
||||||
|
assertThatThrownBy(() -> catering.save(office.id(), withColumnLabel(office, " ")))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("Every column needs a heading");
|
||||||
|
assertThatThrownBy(() -> catering.save(office.id(), withCell(office, "x".repeat(301))))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("300 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replacingThePageNotesIsTheWholeList() {
|
||||||
|
assertThat(catering.replaceNotes(List.of("One term.", " ", "Another term.")))
|
||||||
|
// A blank line in the editor is not a note.
|
||||||
|
.containsExactly("One term.", "Another term.");
|
||||||
|
assertThat(catering.menu().notes()).containsExactly("One term.", "Another term.");
|
||||||
|
|
||||||
|
assertThat(catering.replaceNotes(List.of("Only this one now."))).hasSize(1);
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
assertThat(catering.menu().notes()).containsExactly("Only this one now.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- turning what was read back into what the editor would send ---------------------------
|
||||||
|
|
||||||
|
/** Note the round trip: what came back as "$24" goes out again as "$24" and must still mean 2400. */
|
||||||
|
private static List<CateringMenu.TierEdit> asEdits(CateringMenu.PackageView table) {
|
||||||
|
return table.tiers().stream()
|
||||||
|
.map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price(), t.serves()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CateringMenu.RowEdit> asLines(CateringMenu.PackageView table) {
|
||||||
|
return table.rows().stream()
|
||||||
|
.map(r -> new CateringMenu.RowEdit(r.id(), r.label(), r.values()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table with one column gone, and every line's values narrowed to match — as the screen sends it. */
|
||||||
|
private static CateringMenu.PackageEdit withoutColumn(CateringMenu.PackageView table, int column) {
|
||||||
|
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
|
||||||
|
tiers.remove(column);
|
||||||
|
List<CateringMenu.RowEdit> rows = table.rows().stream().map(row -> {
|
||||||
|
List<String> values = new ArrayList<>(row.values());
|
||||||
|
values.remove(column);
|
||||||
|
return new CateringMenu.RowEdit(row.id(), row.label(), values);
|
||||||
|
}).toList();
|
||||||
|
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, rows, table.notes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CateringMenu.PackageEdit withColumnPrice(CateringMenu.PackageView table, String price) {
|
||||||
|
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
|
||||||
|
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price, null));
|
||||||
|
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CateringMenu.PackageEdit withColumnLabel(CateringMenu.PackageView table, String label) {
|
||||||
|
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
|
||||||
|
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price(), null));
|
||||||
|
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CateringMenu.PackageEdit withCell(CateringMenu.PackageView table, String value) {
|
||||||
|
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(table));
|
||||||
|
List<String> values = new ArrayList<>(rows.get(0).values());
|
||||||
|
values.set(0, value);
|
||||||
|
rows.set(0, new CateringMenu.RowEdit(rows.get(0).id(), rows.get(0).label(), values));
|
||||||
|
return new CateringMenu.PackageEdit(table.name(), table.blurb(), asEdits(table), rows, table.notes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening times, as a list and as an answer.
|
||||||
|
*
|
||||||
|
* <p>No Spring here on purpose: this is a calculation over a constant, and it should be testable at the
|
||||||
|
* speed of a calculation. Every "now" is passed in, so the Sunday-afternoon case doesn't need a Sunday.
|
||||||
|
*/
|
||||||
|
class HoursTest {
|
||||||
|
|
||||||
|
private final Hours hours = new Hours();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collapsesTheWeekTheWayASignWouldWriteIt() {
|
||||||
|
assertThat(hours.week()).extracting(Hours.Span::days, Hours.Span::hours)
|
||||||
|
.containsExactly(
|
||||||
|
org.assertj.core.groups.Tuple.tuple("Tuesday – Friday", "7:00am – 2:00pm"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple("Saturday", "7:00am – 12:00pm"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple("Sunday – Monday", "Closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theWeekStartsOnTheDayTheShopsWeekStarts() {
|
||||||
|
// Not Monday: the shop is shut on Monday, and a list that opens with a closed day reads as an
|
||||||
|
// apology. This is the order the printed list has always used.
|
||||||
|
assertThat(hours.week().getFirst().days()).startsWith("Tuesday");
|
||||||
|
assertThat(hours.week().getLast().closed()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saysHowLongIsLeftWhenTheShopIsOpen() {
|
||||||
|
Hours.Status wednesdayMorning = hours.at(chicago("2026-07-29T09:15"));
|
||||||
|
assertThat(wednesdayMorning.open()).isTrue();
|
||||||
|
assertThat(wednesdayMorning.summary()).isEqualTo("Open until 2:00pm");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countsTheMinuteOfOpeningAsOpenAndTheMinuteOfClosingAsShut() {
|
||||||
|
// The two edges anyone would get wrong, and the ones a customer stands at the door for.
|
||||||
|
assertThat(hours.at(chicago("2026-07-29T07:00")).open()).isTrue();
|
||||||
|
assertThat(hours.at(chicago("2026-07-29T13:59")).open()).isTrue();
|
||||||
|
assertThat(hours.at(chicago("2026-07-29T14:00")).open()).isFalse();
|
||||||
|
assertThat(hours.at(chicago("2026-07-29T06:59")).open()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tellsYouWhenItOpensAgainRatherThanJustSayingClosed() {
|
||||||
|
// Saturday afternoon, shut since noon: the next open day is Tuesday, three days away.
|
||||||
|
Hours.Status saturdayAfternoon = hours.at(chicago("2026-08-01T15:00"));
|
||||||
|
assertThat(saturdayAfternoon.open()).isFalse();
|
||||||
|
assertThat(saturdayAfternoon.summary()).isEqualTo("Closed · opens Tuesday at 7:00am");
|
||||||
|
|
||||||
|
// Monday: tomorrow reads better than naming the day.
|
||||||
|
assertThat(hours.at(chicago("2026-08-03T10:00")).summary())
|
||||||
|
.isEqualTo("Closed · opens tomorrow at 7:00am");
|
||||||
|
|
||||||
|
// Before opening on a day it does open, the day itself is redundant.
|
||||||
|
assertThat(hours.at(chicago("2026-07-29T06:30")).summary()).isEqualTo("Opens at 7:00am");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ZonedDateTime chicago(String localDateTime) {
|
||||||
|
return LocalDateTime.parse(localDateTime).atZone(Shop.ZONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package com.itsthevine.web;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.core.io.DefaultResourceLoader;
|
|
||||||
import org.springframework.core.io.FileSystemResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs against the REAL frontend/index.html rather than a fixture: the controller finds its tags by
|
|
||||||
* pattern, so reformatting that file is exactly how this would silently break. Here it fails the build
|
|
||||||
* instead.
|
|
||||||
*/
|
|
||||||
class PageMetaControllerTest {
|
|
||||||
|
|
||||||
private static final File INDEX = new File("frontend/index.html");
|
|
||||||
|
|
||||||
private static PageMetaController controller() {
|
|
||||||
DefaultResourceLoader loader = new DefaultResourceLoader() {
|
|
||||||
@Override
|
|
||||||
public Resource getResource(String location) {
|
|
||||||
return new FileSystemResource(INDEX);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return new PageMetaController(loader, "https://itsthevine.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String get(String path) {
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
|
|
||||||
request.setRequestURI(path);
|
|
||||||
return controller().page(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void theIndexTemplateIsWhereTheControllerExpects() {
|
|
||||||
assertThat(INDEX).exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void productsPageGetsItsOwnTitleAndDescription() {
|
|
||||||
String html = get("/products");
|
|
||||||
|
|
||||||
assertThat(html).contains("<title>Our products · The Vine Coffeehouse + Bakery</title>");
|
|
||||||
assertThat(html).contains("<meta name=\"description\" content=\"Cinnamon rolls, caramel rolls");
|
|
||||||
assertThat(html).contains("<meta property=\"og:title\" content=\"Our products · The Vine");
|
|
||||||
assertThat(html).contains("<meta property=\"og:url\" content=\"https://itsthevine.com/products\">");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void everyRouteIsRewritten() {
|
|
||||||
// A route the controller maps but forgot to describe would silently serve the homepage's
|
|
||||||
// metadata, which is worse than none — it tells a crawler two URLs are the same page.
|
|
||||||
assertThat(get("/history")).contains("<title>Our story · ");
|
|
||||||
assertThat(get("/contact")).contains("<title>Contact us · ");
|
|
||||||
assertThat(get("/")).contains("<title>The Vine Coffeehouse + Bakery</title>");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void theHomepageOgUrlHasNoTrailingSlash() {
|
|
||||||
assertThat(get("/")).contains("<meta property=\"og:url\" content=\"https://itsthevine.com\">");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void noDefaultMetadataSurvivesOnASubPage() {
|
|
||||||
// The template ships with the homepage copy. If a replacement misses, that copy leaks onto
|
|
||||||
// every page and the whole exercise is pointless.
|
|
||||||
String html = get("/contact");
|
|
||||||
assertThat(html).doesNotContain("A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake");
|
|
||||||
assertThat(html).doesNotContain("<title>The Vine Coffeehouse + Bakery</title>");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void theAppShellIsStillIntact() {
|
|
||||||
// Rewriting the head must not disturb what actually boots the SPA.
|
|
||||||
String html = get("/products");
|
|
||||||
assertThat(html).contains("<div id=\"root\"></div>");
|
|
||||||
assertThat(html).contains("/src/main.tsx");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,40 @@
|
|||||||
package com.itsthevine.web;
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
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.containers.PostgreSQLContainer;
|
||||||
import org.testcontainers.junit.jupiter.Container;
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
import net.thebennett.platform.test.PlatformWebContract;
|
/**
|
||||||
|
* What this app must do because it is on the platform.
|
||||||
/** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */
|
*
|
||||||
|
* <p>It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five
|
||||||
|
* assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts
|
||||||
|
* that an unknown extension-less path forwards to {@code /index.html}, because it was written when every
|
||||||
|
* app on the platform was a React SPA. There is no SPA here at all now — no shell to forward to — so
|
||||||
|
* that assertion describes an app this no longer is. The contract's own test methods are package-private,
|
||||||
|
* so it cannot be overridden from here.
|
||||||
|
*
|
||||||
|
* <p>The other four are restated below unchanged, so this app still fails the build on the regression
|
||||||
|
* the contract exists for (an {@code /api} typo answering with a page and a 200).
|
||||||
|
*
|
||||||
|
* <p><b>Platform follow-up:</b> {@code PlatformWebContract} should decide which of the two routing
|
||||||
|
* behaviours to assert by reading {@code platform.web.spa.enabled} — then one contract would cover both
|
||||||
|
* kinds of app and this file could go back to inheriting it.
|
||||||
|
*/
|
||||||
@SpringBootTest(properties = {
|
@SpringBootTest(properties = {
|
||||||
// The storage starter activates on its default endpoint, so an S3 client is built even in
|
// The storage starter activates on its default endpoint, so an S3 client is built even in
|
||||||
// tests and fails on blank keys.
|
// tests and fails on blank keys.
|
||||||
@@ -19,10 +44,58 @@ import net.thebennett.platform.test.PlatformWebContract;
|
|||||||
"[email protected]"
|
"[email protected]"
|
||||||
})
|
})
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
class PlatformContractTest extends PlatformWebContract {
|
class PlatformContractTest {
|
||||||
|
|
||||||
@Container
|
@Container
|
||||||
@ServiceConnection
|
@ServiceConnection
|
||||||
static PostgreSQLContainer<?> postgres =
|
static PostgreSQLContainer<?> postgres =
|
||||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
WebApplicationContext context;
|
||||||
|
|
||||||
|
MockMvc mvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("an /api path that matches no controller returns 404, not a page")
|
||||||
|
void unknownApiPathIsNotFound() throws Exception {
|
||||||
|
mvc.perform(get("/api/a-path-no-controller-serves")).andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a nested unknown /api path returns 404 too")
|
||||||
|
void unknownNestedApiPathIsNotFound() throws Exception {
|
||||||
|
mvc.perform(get("/api/deeper/still/not/real")).andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("health reports UP")
|
||||||
|
void healthIsUp() throws Exception {
|
||||||
|
mvc.perform(get("/actuator/health"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("UP"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("liveness and readiness probes are exposed")
|
||||||
|
void probesAreExposed() throws Exception {
|
||||||
|
// Docker's HEALTHCHECK and any future orchestrator depend on these existing.
|
||||||
|
mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk());
|
||||||
|
mvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("routing is server-side: an unknown path is a 404, and so is /admin with no identity provider")
|
||||||
|
void routingIsServerSide() throws Exception {
|
||||||
|
mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound());
|
||||||
|
// No SECURITY_MODE here, so AdminController does not exist — "no Authentik configured" means "no
|
||||||
|
// admin", and it 404s like any other unknown path rather than exposing catalogue writes.
|
||||||
|
// AdminSecurityTest covers the other half: with OIDC on, /admin exists and needs a login.
|
||||||
|
mvc.perform(get("/admin")).andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.web.context.WebApplicationContext;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages, rendered.
|
||||||
|
*
|
||||||
|
* <p>These assert what a visitor and a crawler are actually served — the catalogue in the HTML rather
|
||||||
|
* than in a JSON call the page makes later, and a real per-page {@code <title>}. That second one is the
|
||||||
|
* whole reason {@code PageMetaController} existed; this replaces its test.
|
||||||
|
*
|
||||||
|
* <p>They are also the only thing that catches a broken template: a Thymeleaf expression that names a
|
||||||
|
* model attribute wrongly fails at render time, not at compile time.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"platform.storage.access-key=test",
|
||||||
|
"platform.storage.secret-key=test",
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]",
|
||||||
|
"site.base-url=https://itsthevine.test",
|
||||||
|
"site.assets.base-url=https://s3.example.test/itsthevine"
|
||||||
|
})
|
||||||
|
@Testcontainers
|
||||||
|
class SiteControllerTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
static PostgreSQLContainer<?> postgres =
|
||||||
|
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
WebApplicationContext context;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
com.itsthevine.web.domain.ContactEnquiryRepository enquiries;
|
||||||
|
|
||||||
|
MockMvc mvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mvc = MockMvcBuilders.webAppContextSetup(context).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyPageStatesItsOwnTitleAndDescription() throws Exception {
|
||||||
|
// One generic shell for every page was the SPA's problem, and the reason a controller used to
|
||||||
|
// rewrite the head with regular expressions.
|
||||||
|
mvc.perform(get("/")).andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("<title>The Vine Coffeehouse + Bakery</title>")))
|
||||||
|
.andExpect(content().string(containsString("A locally owned coffeehouse and bakery")))
|
||||||
|
.andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test\"")));
|
||||||
|
mvc.perform(get("/products"))
|
||||||
|
.andExpect(content().string(containsString("<title>Our products · The Vine Coffeehouse + Bakery</title>")))
|
||||||
|
.andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test/products\"")));
|
||||||
|
mvc.perform(get("/catering"))
|
||||||
|
.andExpect(content().string(containsString("<title>Goodie boxes & catering · The Vine Coffeehouse + Bakery</title>")));
|
||||||
|
mvc.perform(get("/history"))
|
||||||
|
.andExpect(content().string(containsString("<title>Our story · The Vine Coffeehouse + Bakery</title>")));
|
||||||
|
mvc.perform(get("/contact"))
|
||||||
|
.andExpect(content().string(containsString("<title>Contact us · The Vine Coffeehouse + Bakery</title>")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theCatalogueIsInTheHtmlRatherThanFetchedAfterwards() throws Exception {
|
||||||
|
mvc.perform(get("/products")).andExpect(status().isOk())
|
||||||
|
// A real product, its category, and a photo URL built from the bucket config.
|
||||||
|
.andExpect(content().string(containsString("76th Birthday Cake")))
|
||||||
|
.andExpect(content().string(containsString("https://s3.example.test/itsthevine/images/")))
|
||||||
|
// The filter buttons are links now, so every filtered view is a URL a crawler can follow.
|
||||||
|
.andExpect(content().string(containsString("href=\"/products?category=Cakes\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theCategoryFilterIsAppliedByTheServer() throws Exception {
|
||||||
|
mvc.perform(get("/products").param("category", "Pie")).andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("Blueberry Cream Pie")))
|
||||||
|
.andExpect(content().string(not(containsString("76th Birthday Cake"))))
|
||||||
|
// The chosen one is marked for a screen reader, not just coloured in.
|
||||||
|
.andExpect(content().string(containsString("aria-current=\"page\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theCateringTablesAreRenderedFromTheDatabase() throws Exception {
|
||||||
|
mvc.perform(get("/catering")).andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("Office boxes")))
|
||||||
|
.andExpect(content().string(containsString("Weddings")))
|
||||||
|
// Prices as the server writes them — the page never formats money.
|
||||||
|
.andExpect(content().string(containsString("$24")))
|
||||||
|
.andExpect(content().string(containsString("$236")))
|
||||||
|
// A cell and a note, in the wording a customer reads rather than the spreadsheet's.
|
||||||
|
// The office box is a total mixed in sixes, not three separate things.
|
||||||
|
.andExpect(content().string(containsString("Any mix of mini muffins")))
|
||||||
|
.andExpect(content().string(containsString("18 items")))
|
||||||
|
.andExpect(content().string(containsString("About 6–8 people")))
|
||||||
|
.andExpect(content().string(containsString("Baked in sixes")))
|
||||||
|
// One card per size, and one enquiry link per table — not one per card, which would have
|
||||||
|
// read "Ask about the 15–20 people".
|
||||||
|
// The size label is in the HTML as written; the small caps are CSS.
|
||||||
|
.andExpect(content().string(containsString("Large")))
|
||||||
|
.andExpect(content().string(containsString("Ask about office boxes")))
|
||||||
|
// The price grid is gone: it was the source spreadsheet, rendered.
|
||||||
|
.andExpect(content().string(not(containsString("<table"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thePagesShareOneHeaderThatOffersTheCateringPage() throws Exception {
|
||||||
|
// A page nobody can navigate to isn't finished.
|
||||||
|
mvc.perform(get("/")).andExpect(content().string(containsString("href=\"/catering\"")));
|
||||||
|
mvc.perform(get("/products")).andExpect(content().string(containsString("href=\"/catering\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theNavMarksThePageYouAreOn() throws Exception {
|
||||||
|
// Generated from the path the controller set, so a wrong model attribute would mark nothing at
|
||||||
|
// all — and nothing at all looks exactly like a page that simply has no active link.
|
||||||
|
mvc.perform(get("/catering"))
|
||||||
|
.andExpect(content().string(containsString("href=\"/catering\" aria-current=\"page\"")));
|
||||||
|
mvc.perform(get("/products"))
|
||||||
|
.andExpect(content().string(containsString("href=\"/products\" aria-current=\"page\"")))
|
||||||
|
.andExpect(content().string(not(containsString("href=\"/catering\" aria-current"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyPageOffersTheKeyboardAWayPastTheNav() throws Exception {
|
||||||
|
mvc.perform(get("/"))
|
||||||
|
.andExpect(content().string(containsString("href=\"#content\"")))
|
||||||
|
.andExpect(content().string(containsString("id=\"content\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCateringButtonStartsTheEnquiryOffAboutThatTable() throws Exception {
|
||||||
|
mvc.perform(get("/contact").param("about", "Weddings"))
|
||||||
|
.andExpect(content().string(containsString("like to ask about weddings catering")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anAboutParameterThatIsntATableIsIgnored() throws Exception {
|
||||||
|
// The value lands in a box on the page, so it is matched against the real table names rather
|
||||||
|
// than echoed. Thymeleaf escapes it either way; a link that puts someone else's words in front
|
||||||
|
// of a customer still shouldn't work.
|
||||||
|
mvc.perform(get("/contact").param("about", "<script>alert(1)</script>"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(not(containsString("<script>alert(1)</script>"))))
|
||||||
|
.andExpect(content().string(not(containsString("like to ask about"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFilledInTrapIsThankedAndThrownAway() throws Exception {
|
||||||
|
// The bot is told the same thing a person is told — anything else is a training signal — and
|
||||||
|
// nothing is recorded or sent. The enquiry table is what proves the second half.
|
||||||
|
mvc.perform(post("/contact")
|
||||||
|
.param("name", "Bot").param("email", "[email protected]")
|
||||||
|
.param("message", "Cheap watches").param("website", "http://example.com"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("Your message is on its way")));
|
||||||
|
assertThat(enquiries.count()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anUnknownPageIsNotFound() throws Exception {
|
||||||
|
// Status only: MockMvc does not run the servlet container's error dispatch, so the body of the
|
||||||
|
// rendered error/404.html page can't be asserted here. It is checked against a running container
|
||||||
|
// instead — the page itself is a template like any other, and the layout it uses is covered above.
|
||||||
|
mvc.perform(get("/no-such-page")).andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
}
|
||||||